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
32 changes: 31 additions & 1 deletion agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -3342,7 +3342,10 @@ private Flux<AgentEvent> runToolBatch(
.getId(),
entry.getKey()
.getName(),
state)
state,
finalToolResultText(
entry
.getValue()))
.withMetadata(
entry.getValue()
.getMetadata()));
Expand Down Expand Up @@ -3499,6 +3502,33 @@ private void emitToolResultDelta(
}
}

/**
* Join the text blocks of a tool method's return value, reported on {@link
* ToolResultEndEvent#getFinalResultText()}.
*
* <p>Progress chunks emitted through {@link io.agentscope.core.tool.ToolEmitter} travel the
* event stream as deltas but are deliberately not what the model receives, so a consumer
* rebuilding the tool message from deltas needs the return value from this event instead.
*
* <p>An empty join is reported as {@code ""}, not {@code null}: a tool that returns an image
* or a blank string did produce a return value, and a consumer that fell back to the delta
* buffer for it would persist progress text as the result — the exact leak this field exists
* to close. Only a result we know nothing about is left unreported.
*
* @return joined text (possibly empty), or {@code null} when there are no content blocks to
* report from
*/
private String finalToolResultText(ToolResultBlock result) {
if (result == null || result.getOutput() == null || result.getOutput().isEmpty()) {
return null;
}
return result.getOutput().stream()
.filter(TextBlock.class::isInstance)
.map(block -> ((TextBlock) block).getText())
.filter(value -> value != null && !value.isEmpty())
.collect(Collectors.joining());
}

private ToolResultState determineToolResultState(ToolResultBlock result) {
if (result.isSuspended()) {
return ToolResultState.RUNNING;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ public class ToolResultEndEvent extends AgentEvent {
private final String toolCallName;
private final ToolResultState state;

/**
* The tool method's return value as text, or {@code null} when the producer reports nothing.
*
* <p>{@code ""} is a reported value, not an absent one: it means the tool returned content with
* no text in it (an image, or a blank result), and a consumer must not fall back to its delta
* buffer for that case.
*
* <p>This deliberately does not ride on {@link AgentEvent#getMetadata()}, which passes the tool
* result's own metadata through unchanged — {@code ReActAgentNewLoopE2ETest} asserts that map
* exactly, so a framework key in it would be a contract change for unrelated consumers.
*/
private final String finalResultText;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] The new field is honoured only by the AG-UI adapter. Other first-party consumers of the same event stream still derive the persisted tool result purely from the delta buffer (e.g. agentscope-service/service-dataplane/.../managed/SessionEventMapper.java, which builds AGENT_TOOL_RESULT output/text/content from ToolResultBuffer.outputText()), so managed sessions keep recording progress text as the result and now disagree with what AG-UI clients show for the same call. Suggest resolving getFinalResultText() (falling back to the buffer) in that path too, so every consumer of ToolResultEndEvent agrees.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] Compatibility nit on the wire format: ToolResultEndEvent crosses process/persistence boundaries as JSON (RemoteEventCodec, session replay). Please confirm the new @JsonCreator property round-trips on both sides before/after rollout, so an older consumer deserialising a newer payload (and vice versa) does not lose the authoritative result text silently.


@JsonCreator
public ToolResultEndEvent(
@JsonProperty("id") String id,
Expand All @@ -35,12 +48,14 @@ public ToolResultEndEvent(
@JsonProperty("toolCallId") String toolCallId,
@JsonProperty("toolCallName") String toolCallName,
@JsonProperty("state") ToolResultState state,
@JsonProperty("metadata") Map<String, Object> metadata) {
@JsonProperty("metadata") Map<String, Object> metadata,
@JsonProperty("finalResultText") String finalResultText) {
super(id, createdAt);
this.replyId = replyId;
this.toolCallId = toolCallId;
this.toolCallName = toolCallName;
this.state = state;
this.finalResultText = finalResultText;
this.withMetadata(metadata);
}

Expand All @@ -54,15 +69,29 @@ public ToolResultEndEvent(
String toolCallId,
String toolCallName,
ToolResultState state) {
this(id, createdAt, replyId, toolCallId, toolCallName, state, null);
this(id, createdAt, replyId, toolCallId, toolCallName, state, null, null);
}

public ToolResultEndEvent(
String replyId, String toolCallId, String toolCallName, ToolResultState state) {
this(replyId, toolCallId, toolCallName, state, null);
}

/**
* As {@link #ToolResultEndEvent(String, String, String, ToolResultState)} additionally reporting
* the tool method's return value.
*/
public ToolResultEndEvent(
String replyId,
String toolCallId,
String toolCallName,
ToolResultState state,
String finalResultText) {
this.replyId = replyId;
this.toolCallId = toolCallId;
this.toolCallName = toolCallName;
this.state = state;
this.finalResultText = finalResultText;
}

@Override
Expand All @@ -85,4 +114,14 @@ public String getToolCallName() {
public ToolResultState getState() {
return state;
}

/**
* The tool method's return value as text.
*
* @return the return value (possibly empty), or {@code null} when the producer reports nothing,
* which leaves consumers falling back to the delta stream
*/
public String getFinalResultText() {
return finalResultText;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@
import io.agentscope.core.event.ToolResultEndEvent;
import io.agentscope.core.event.ToolResultTextDeltaEvent;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.ImageBlock;
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 io.agentscope.core.message.URLSource;
import io.agentscope.core.middleware.ActingInput;
import io.agentscope.core.middleware.AgentInput;
import io.agentscope.core.middleware.MiddlewareBase;
Expand Down Expand Up @@ -416,4 +418,103 @@ void streamEventsRestoresEmitterWhenActingContextLosesEventKeys() {
assertTrue(events.get(0) instanceof AgentStartEvent);
assertTrue(events.get(events.size() - 1) instanceof AgentEndEvent);
}

@Test
void toolResultEndEventReportsTheReturnValueWhenTheToolAlsoStreamedProgress() {
List<AgentEvent> events = runWithProgressTool(ToolResultBlock.text("fingerprint enrolled"));

// Without this the test would still pass if the tool never emitted anything, and the field
// under test would be trivially correct — the progress chunk is the leak being closed.
assertTrue(
events.stream()
.filter(ToolResultTextDeltaEvent.class::isInstance)
.map(e -> ((ToolResultTextDeltaEvent) e).getDelta())
.anyMatch("scanning 3 dirs"::equals),
"the progress chunk has to reach the stream");
assertEquals(
"fingerprint enrolled",
lastToolResultEnd(events).getFinalResultText(),
"the model received the return value, so that is what the end event reports");
}

@Test
void imageOnlyReturnValueIsReportedAsEmptyRatherThanAbsent() {
// "" and null mean different things downstream: null tells a consumer to fall back to the
// deltas it buffered, which would feed progress text back as the tool result on replay.
List<AgentEvent> events =
runWithProgressTool(
ToolResultBlock.of(
ImageBlock.builder()
.source(new URLSource("https://example.com/chart.png"))
.build()));

assertEquals("", lastToolResultEnd(events).getFinalResultText());
}

private List<AgentEvent> runWithProgressTool(ToolResultBlock returnValue) {
ScriptedModel model =
new ScriptedModel(
List.of(
() -> Flux.just(toolUseResponse("c1", "enroll", "alpha")),
() -> Flux.just(textResponse("done"))));
Toolkit tk = new Toolkit();
tk.registerAgentTool(new ProgressTool("enroll", returnValue));

List<AgentEvent> events =
ReActAgent.builder()
.name("asst")
.sysPrompt("you are helpful")
.model(model)
.toolkit(tk)
.build()
.streamEvents(
List.of(
Msg.builder()
.role(MsgRole.USER)
.textContent("run enroll")
.build()))
.collectList()
.block();
assertNotNull(events);
return events;
}

private static ToolResultEndEvent lastToolResultEnd(List<AgentEvent> events) {
return events.stream()
.filter(ToolResultEndEvent.class::isInstance)
.map(ToolResultEndEvent.class::cast)
.reduce((first, second) -> second)
.orElseThrow();
}

/** Streams one progress chunk, then returns {@code returnValue}: the {@code ToolEmitter} deal. */
private static final class ProgressTool extends ToolBase {
private final ToolResultBlock returnValue;

ProgressTool(String name, ToolResultBlock returnValue) {
super(
name,
"streams progress then returns",
AlwaysAllowTool.schema(),
true,
true,
false,
null,
false,
false);
this.returnValue = returnValue;
}

@Override
public Mono<PermissionDecision> checkPermissions(
Map<String, Object> input, PermissionContextState ctx) {
return Mono.just(PermissionDecision.allow("ok"));
}

@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
param.getEmitter().emit(ToolResultBlock.text("scanning 3 dirs"));
return Mono.just(returnValue);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
package io.agentscope.core.event;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

Expand Down Expand Up @@ -190,6 +192,61 @@ void toolResultStartEventEmptyMetadata() throws Exception {
deserialized.getMetadata() == null || deserialized.getMetadata().isEmpty(),
"ToolResultStartEvent should not carry result metadata before execution");
}

@Test
@DisplayName("finalResultText survives the polymorphic round trip")
void finalResultTextSurvivesRoundTrip() throws Exception {
ToolResultEndEvent original =
new ToolResultEndEvent(
"reply-1",
"tc-1",
"search",
ToolResultState.SUCCESS,
"fingerprint enrolled");

String json = mapper.writeValueAsString(original);
assertTrue(
json.contains("\"finalResultText\":\"fingerprint enrolled\""),
"the new property has to be on the wire, not just in the object: " + json);

ToolResultEndEvent back =
assertInstanceOf(
ToolResultEndEvent.class, mapper.readValue(json, AgentEvent.class));
assertEquals("fingerprint enrolled", back.getFinalResultText());
}

@Test
@DisplayName("an empty return value decodes as empty, not as absent")
void emptyFinalResultTextIsNotLossy() throws Exception {
// "" carries meaning: the tool did return content, it just holds no text. Read back as
// null it would send consumers falling back to the delta buffer, re-opening the leak.
ToolResultEndEvent original =
new ToolResultEndEvent(
"reply-1", "tc-1", "search", ToolResultState.SUCCESS, "");

ToolResultEndEvent back =
assertInstanceOf(
ToolResultEndEvent.class,
mapper.readValue(
mapper.writeValueAsString(original), AgentEvent.class));
assertEquals("", back.getFinalResultText());
}

@Test
@DisplayName("a payload written before the field existed decodes to null")
void absentFinalResultTextDecodesToNull() throws Exception {
// Sessions persisted by an older build are reloaded through this same creator.
ToolResultEndEvent back =
assertInstanceOf(
ToolResultEndEvent.class,
mapper.readValue(
"{\"type\":\"TOOL_RESULT_END\",\"replyId\":\"r\","
+ "\"toolCallId\":\"t\",\"toolCallName\":\"n\","
+ "\"state\":\"success\"}",
AgentEvent.class));
assertEquals("r", back.getReplyId());
assertNull(back.getFinalResultText());
}
}

@Nested
Expand Down
Loading
Loading