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
13 changes: 8 additions & 5 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -1960,8 +1960,10 @@ private Mono<Msg> reasoning(int iter, boolean ignoreMaxIters) {
options);
}
List<Msg> modelInput =
prependSystemMsg(
event.getInputMessages(), event.getSystemMessage());
MessageUtils.normalizeToolCallResults(
prependSystemMsg(
event.getInputMessages(),
event.getSystemMessage()));
List<ToolSchema> tools =
toolkit.getToolSchemas(
state.getToolContext().getActivatedGroups());
Expand Down Expand Up @@ -3084,9 +3086,10 @@ protected Mono<Msg> summarizing() {
.flatMap(
preSummaryEvent -> {
List<Msg> effectiveMessages =
prependSystemMsg(
preSummaryEvent.getInputMessages(),
preSummaryEvent.getSystemMessage());
MessageUtils.normalizeToolCallResults(
prependSystemMsg(
preSummaryEvent.getInputMessages(),
preSummaryEvent.getSystemMessage()));
GenerateOptions effectiveOptions =
preSummaryEvent.getEffectiveGenerateOptions();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,20 @@
*/
package io.agentscope.core.util;

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.ToolResultBlock;
import io.agentscope.core.message.ToolResultState;
import io.agentscope.core.message.ToolUseBlock;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

/**
* Utility methods for message processing and extraction.
Expand Down Expand Up @@ -63,4 +72,102 @@ public static List<ToolUseBlock> extractRecentToolCalls(List<Msg> messages, Stri

return List.of();
}

/**
* Builds a provider-safe copy of a transcript by placing each tool result immediately after
* the assistant message that owns its tool call.
*
* <p>If an earlier interruption left a tool call without any result in the transcript, this
* method adds a terminal error result. It does not mutate the supplied messages.
*
* @param messages messages to normalize
* @return a normalized transcript, or an empty list when the input is null or empty
*/
public static List<Msg> normalizeToolCallResults(List<Msg> messages) {
if (messages == null || messages.isEmpty()) {
return List.of();
}

Map<String, ToolResultBlock> resultsById = new HashMap<>();
for (Msg message : messages) {
if (message == null) {
continue;
}
for (ToolResultBlock result : message.getContentBlocks(ToolResultBlock.class)) {
if (result.getId() != null) {
resultsById.putIfAbsent(result.getId(), result);
}
}
}

List<Msg> normalized = new ArrayList<>(messages.size());
Set<String> relocatedResultIds = new HashSet<>();
for (Msg message : messages) {
if (message == null) {
continue;
}
if (message.getRole() == MsgRole.ASSISTANT) {
List<ToolUseBlock> toolUses = message.getContentBlocks(ToolUseBlock.class);
if (!toolUses.isEmpty()) {
Set<String> toolUseIds = new HashSet<>();
for (ToolUseBlock toolUse : toolUses) {
if (toolUse.getId() != null) {
toolUseIds.add(toolUse.getId());
}
}
List<ContentBlock> assistantContent = new ArrayList<>(message.getContent());
assistantContent.removeIf(
block ->
block instanceof ToolResultBlock result
&& toolUseIds.contains(result.getId()));
normalized.add(
assistantContent.equals(message.getContent())
? message
: message.withContent(assistantContent));
for (ToolUseBlock toolUse : toolUses) {
if (toolUse.getId() == null) {
continue;
}
ToolResultBlock result = resultsById.get(toolUse.getId());
if (result == null) {
result = interruptedToolResult(toolUse);
}
relocatedResultIds.add(toolUse.getId());
normalized.add(Msg.builder().role(MsgRole.TOOL).content(result).build());
}
continue;
}
}

if (message.getRole() == MsgRole.TOOL && !relocatedResultIds.isEmpty()) {
List<ContentBlock> retained = new ArrayList<>();
for (ContentBlock block : message.getContent()) {
if (!(block instanceof ToolResultBlock result)
|| !relocatedResultIds.contains(result.getId())) {
retained.add(block);
}
}
if (!retained.isEmpty()) {
normalized.add(message.withContent(retained));
}
continue;
}

normalized.add(message);
}
return normalized;
}

private static ToolResultBlock interruptedToolResult(ToolUseBlock toolUse) {
return new ToolResultBlock(
toolUse.getId(),
toolUse.getName(),
TextBlock.builder()
.text(
"Previous tool call was interrupted before a result was"
+ " recorded. Treat this tool call as failed or"
+ " canceled.")
.build())
.withState(ToolResultState.ERROR);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ private static final class ScriptedModel extends ChatModelBase {
private final List<Supplier<Flux<ChatResponse>>> scripts;
private final AtomicInteger idx = new AtomicInteger(0);
final AtomicInteger calls = new AtomicInteger(0);
final List<List<Msg>> inputs = new ArrayList<>();

ScriptedModel(List<Supplier<Flux<ChatResponse>>> scripts) {
this.scripts = scripts;
Expand All @@ -81,6 +82,7 @@ public String getModelName() {
protected Flux<ChatResponse> doStream(
List<Msg> messages, List<ToolSchema> tools, GenerateOptions options) {
calls.incrementAndGet();
inputs.add(List.copyOf(messages));
int i = idx.getAndIncrement();
if (i >= scripts.size()) {
return Flux.just(textResponse(""));
Expand Down Expand Up @@ -292,4 +294,46 @@ void toolReturningErrorBlockEmitsErrorResultEndState() {
.orElseThrow();
assertEquals(ToolResultState.ERROR, end.getState());
}

@Test
void normalizesInlineToolResultsBeforeCallingTheModelWithoutMutatingState() {
ScriptedModel model = new ScriptedModel(List.of(() -> Flux.just(textResponse("done"))));
ReActAgent agent =
ReActAgent.builder().name("asst").sysPrompt("you are helpful").model(model).build();
ToolUseBlock toolUse =
ToolUseBlock.builder().id("call-history").name("search").input(Map.of()).build();
ToolResultBlock toolResult =
ToolResultBlock.of(
"call-history", "search", TextBlock.builder().text("found").build());
Msg historicalAssistant =
Msg.builder().role(MsgRole.ASSISTANT).content(List.of(toolUse, toolResult)).build();
agent.getAgentState().contextMutable().add(historicalAssistant);

agent.streamEvents(
List.of(Msg.builder().role(MsgRole.USER).textContent("continue").build()))
.collectList()
.block();

List<Msg> modelInput = model.inputs.get(0);
int toolUseIndex = -1;
for (int index = 0; index < modelInput.size(); index++) {
boolean containsHistoricalToolUse =
modelInput.get(index).getContentBlocks(ToolUseBlock.class).stream()
.anyMatch(block -> "call-history".equals(block.getId()));
if (containsHistoricalToolUse) {
toolUseIndex = index;
break;
}
}
assertTrue(toolUseIndex >= 0, "model input should include the historical tool use");
assertTrue(modelInput.get(toolUseIndex).getContentBlocks(ToolResultBlock.class).isEmpty());
assertEquals(MsgRole.TOOL, modelInput.get(toolUseIndex + 1).getRole());
assertEquals(
"call-history",
modelInput
.get(toolUseIndex + 1)
.getFirstContentBlock(ToolResultBlock.class)
.getId());
assertEquals(1, historicalAssistant.getContentBlocks(ToolResultBlock.class).size());
}
}
Loading
Loading