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
103 changes: 101 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 @@ -1692,6 +1692,11 @@ final class CallExecution {
/** The tool result message from the successful {@code generate_response} call. */
Msg soResultMsg;

/** Placeholder sentence written to the tool_result of a returnDirect tool. */
private static final String RETURN_DIRECT_PLACEHOLDER =
"Tool call completed. The result has been presented to the user as the final output"
+ " of this turn.";

/** Native structured-output format set on the per-call scope for native-path calls. */
ResponseFormat nativeResponseFormat;

Expand Down Expand Up @@ -2785,8 +2790,14 @@ private Mono<Msg> acting(int iter) {
return executeIteration(iter + 1);
}

boolean returnDirect =
pendingPairs.isEmpty()
&& !successPairs.isEmpty()
&& successPairs.stream()
.allMatch(this::isReturnDirectToolCall);

return Flux.fromIterable(successPairs)
.concatMap(this::notifyPostActingHook)
.concatMap(e -> notifyPostActingHook(e, returnDirect))
.last()
.flatMap(
event -> {
Expand All @@ -2798,6 +2809,15 @@ private Mono<Msg> acting(int iter) {
.ACTING_STOP_REQUESTED));
}

if (returnDirect) {
Msg result =
buildReturnDirectResultMsg(
successPairs);
state.contextMutable().add(result);
logReturnDirect(successPairs);
return Mono.just(result);
}

if (!pendingPairs.isEmpty()) {
return Mono.just(
buildSuspendedMsg(pendingPairs));
Expand Down Expand Up @@ -3461,9 +3481,14 @@ private Mono<ToolResultBlock> executeStructuredTool(ToolUseBlock use) {

/**
* Fire PostActingEvent for a single tool result, build message and add to context.
*
* <p>When {@code returnDirect} is {@code true} (the whole batch is being returned
* directly) and the hook did not stop, the tool result written to context is replaced
* with the placeholder sentence; the full result is kept on the hook event for
* auditing and later lifted into the closing assistant message.
*/
private Mono<PostActingEvent> notifyPostActingHook(
Map.Entry<ToolUseBlock, ToolResultBlock> entry) {
Map.Entry<ToolUseBlock, ToolResultBlock> entry, boolean returnDirect) {
ToolUseBlock toolUse = entry.getKey();
ToolResultBlock result = entry.getValue();

Expand Down Expand Up @@ -3491,10 +3516,84 @@ private Mono<PostActingEvent> notifyPostActingHook(
e.stopAgent();
}
Msg resultMsg = e.getToolResultMsg();
if (returnDirect && !e.isStopRequested()) {
resultMsg =
buildReturnDirectPlaceholderMsg(toolUse, updatedResult);
log.debug(
"returnDirect: replaced tool result with placeholder"
+ " for tool '{}'",
toolUse.getName());
}
state.contextMutable().add(resultMsg);
});
}

/**
* Whether a single tool result may participate in the returnDirect short-circuit: the tool
* declared {@code returnDirect = true} and its result actually executed successfully.
* DENIED / ERROR / INTERRUPTED results must never be presented as the final answer.
*/
private boolean isReturnDirectToolCall(Map.Entry<ToolUseBlock, ToolResultBlock> entry) {
AgentTool tool = toolkit.getTool(entry.getKey().getName());
if (tool == null || !tool.isReturnDirect()) {
return false;
}
return determineToolResultState(entry.getValue()) == ToolResultState.SUCCESS;
}

/** Builds the placeholder tool_result, replacing only the output while keeping id/name/state. */
private Msg buildReturnDirectPlaceholderMsg(ToolUseBlock toolUse, ToolResultBlock result) {
ToolResultBlock placeholder =
ToolResultBlock.builder()
.id(toolUse.getId())
.name(toolUse.getName())
.output(TextBlock.builder().text(RETURN_DIRECT_PLACEHOLDER).build())
.state(result.getState())
.build();
return ToolResultMessageBuilder.buildToolResultMsg(placeholder, toolUse, getName());
}

/**
* Logs the returnDirect short-circuit: {@code info} marks that the tool result(s) were
* returned directly as the final answer, {@code debug} adds the tool count and names.
*/
private void logReturnDirect(List<Map.Entry<ToolUseBlock, ToolResultBlock>> pairs) {
List<String> names = pairs.stream().map(e -> e.getKey().getName()).toList();
log.info(
"returnDirect: returning tool result(s) for {} directly as the final answer",
String.join(", ", names));
log.debug("returnDirect: {} tool result(s) from tools {}", pairs.size(), names);
}

/**
* Synthesises the closing assistant message carrying the full tool result, mimicking the
* final answer the model would otherwise have produced.
*
* <p>For multiple tools the output blocks are concatenated in execution order (= {@code
* pairs} order = the model's tool_calls order) without inlining tool names or adding
* separator blocks; clients correlate blocks to tools via block order + the event stream's
* tool id/name. A tool with empty output contributes a {@code "(no output)"} text block so
* ordering never shifts and the closing message is never empty.
*/
private Msg buildReturnDirectResultMsg(
List<Map.Entry<ToolUseBlock, ToolResultBlock>> pairs) {
List<ContentBlock> content = new ArrayList<>();
for (Map.Entry<ToolUseBlock, ToolResultBlock> pair : pairs) {
List<ContentBlock> output = pair.getValue().getOutput();
if (output.isEmpty()) {
content.add(TextBlock.builder().text("(no output)").build());
} else {
content.addAll(output);
}
}
return AssistantMessage.builder()
.name(getName())
.content(content)
.metadata(Map.of(MessageMetadataKeys.TOOL_RETURN_DIRECT, true))
.generateReason(GenerateReason.TOOL_RETURN_DIRECT)
.build();
}

/**
* Generate summary when max iterations reached.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,8 @@ public enum GenerateReason {
INTERRUPTED,

/** Maximum iterations reached. */
MAX_ITERATIONS
MAX_ITERATIONS,

/** Tool result returned directly to the caller without a follow-up model call. */
TOOL_RETURN_DIRECT
}
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,12 @@ private MessageMetadataKeys() {
* }</pre>
*/
public static final String CACHE_CONTROL = "_cache_control";

/**
* Metadata key marking an assistant message synthesized by the {@code returnDirect}
* tool path (the tool result presented as the turn's final answer).
*
* <p><b>Type:</b> Boolean
*/
public static final String TOOL_RETURN_DIRECT = "_tool_return_direct";
}
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,7 @@ private static double toDouble(Object value) {
* <li>{@link GenerateReason#ACTING_STOP_REQUESTED} - HITL stop in acting phase</li>
* <li>{@link GenerateReason#INTERRUPTED} - Agent was interrupted</li>
* <li>{@link GenerateReason#MAX_ITERATIONS} - Maximum iterations reached</li>
* <li>{@link GenerateReason#TOOL_RETURN_DIRECT} - Tool result returned directly to the caller</li>
* </ul>
*
* @return The generate reason, defaults to {@link GenerateReason#MODEL_STOP} if not set
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ default boolean isReadOnly() {
return false;
}

/**
* Whether this tool's result should be returned directly to the caller,
* skipping the next reasoning iteration. Defaults to {@code false}.
*/
default boolean isReturnDirect() {
return false;
}

/**
* Execute the tool with the given parameters (asynchronous).
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ static ReflectiveFunctionTool create(
.readOnly(annotation.readOnly())
.concurrencySafe(annotation.concurrencySafe())
.externalTool(annotation.externalTool())
.stateInjected(annotation.stateInjected());
.stateInjected(annotation.stateInjected())
.returnDirect(annotation.returnDirect());
if (annotation.dangerousFiles().length > 0) {
builder.dangerousFiles(List.of(annotation.dangerousFiles()));
}
Expand Down
12 changes: 12 additions & 0 deletions agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,18 @@
*/
boolean stateInjected() default false;

/**
* Whether to return this tool's result directly to the caller as the turn's final
* assistant message, bypassing the next model reasoning iteration.
*
* <p>In a batch, this takes effect only when <em>every</em> executed tool in the
* round has {@code returnDirect = true} <em>and</em> the round yields only
* {@code SUCCESS} results; otherwise all results are fed back to the model.
*
* @return true to short-circuit the ReAct loop after execution
*/
boolean returnDirect() default false;

/**
* Sensitive filenames that must require explicit permission for this tool.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public abstract class ToolBase implements AgentTool {
private final boolean stateInjected;
private final boolean mcp;
private final String mcpName;
private final boolean returnDirect;

/** Sensitive files; subclasses may replace this list to widen or narrow protection. */
protected List<String> dangerousFiles = ToolDangerousPathConstants.DEFAULT_DANGEROUS_FILES;
Expand All @@ -87,7 +88,8 @@ protected ToolBase(Builder builder) {
builder.mcp,
builder.mcpName,
builder.externalTool,
builder.stateInjected);
builder.stateInjected,
builder.returnDirect);
if (builder.dangerousFiles != null) {
this.dangerousFiles = List.copyOf(builder.dangerousFiles);
}
Expand All @@ -110,6 +112,30 @@ protected ToolBase(
String mcpName,
boolean externalTool,
boolean stateInjected) {
this(
name,
description,
inputSchema,
readOnly,
concurrencySafe,
mcp,
mcpName,
externalTool,
stateInjected,
false);
}

private ToolBase(
String name,
String description,
Map<String, Object> inputSchema,
boolean readOnly,
boolean concurrencySafe,
boolean mcp,
String mcpName,
boolean externalTool,
boolean stateInjected,
boolean returnDirect) {
this.name = Objects.requireNonNull(name, "name must not be null");
this.description = Objects.requireNonNull(description, "description must not be null");
this.inputSchema = Objects.requireNonNull(inputSchema, "inputSchema must not be null");
Expand All @@ -119,6 +145,7 @@ protected ToolBase(
this.mcpName = mcpName;
this.externalTool = externalTool;
this.stateInjected = stateInjected;
this.returnDirect = returnDirect;
if (mcp && (mcpName == null || mcpName.isBlank())) {
throw new IllegalArgumentException("mcpName is required when mcp is true");
}
Expand Down Expand Up @@ -148,6 +175,18 @@ public final boolean isReadOnly() {
return readOnly;
}

/**
* Whether this tool's result should short-circuit the ReAct loop and be returned directly
* to the caller as the turn's final answer, instead of being fed back to the model for
* another reasoning iteration. Defaults to {@code false}.
*
* @return {@code true} to return the result directly and skip the follow-up model call
*/
@Override
public final boolean isReturnDirect() {
return returnDirect;
}

public final boolean isExternalTool() {
return externalTool;
}
Expand Down Expand Up @@ -280,6 +319,7 @@ public static final class Builder {
private boolean concurrencySafe = true;
private boolean externalTool = false;
private boolean stateInjected = false;
private boolean returnDirect = false;
private boolean mcp = false;
private String mcpName;
private List<String> dangerousFiles;
Expand Down Expand Up @@ -322,6 +362,12 @@ public Builder stateInjected(boolean stateInjected) {
return this;
}

/** Sets whether this tool's result should be returned directly to the caller. */
public Builder returnDirect(boolean returnDirect) {
this.returnDirect = returnDirect;
return this;
}

/** Marks the tool as an MCP tool and records the MCP server name. */
public Builder mcp(String mcpName) {
this.mcp = true;
Expand Down
Loading
Loading