From 27bc180955dfb2fcc1dcee69a035b08128635de7 Mon Sep 17 00:00:00 2001 From: keep simple <3132670669@qq.com> Date: Thu, 25 Jun 2026 19:45:12 +0800 Subject: [PATCH 1/3] feat(tool): add title field to tool call chain for human-readable display Add an optional `title` field throughout the tool invocation pipeline, providing a human-readable label alongside the machine-oriented `name`. When no title is defined, consumers should fall back to `name` for display. Feature changes: - AgentTool: add default getTitle() returning getName() - ToolBase / ToolSchema / SchemaOnlyTool / McpTool: support title in builder & getter - ToolUseBlock / ToolResultBlock: add title field, withTitle(), withIdAndNameAndTitle() - Event classes (ToolCallStart/End, ToolResultStart/End): add toolCallTitle - ToolUtils: new resolveToolTitle() to look up title from Toolkit - LegacyHookDispatcher / ReActAgent: resolve and set title on ToolUseBlock - ToolResultMessageBuilder: propagate title from originalCall - ToolSchemaProvider: pass tool.getTitle() when building schema - AsyncToolMiddleware / PlanModeMiddleware: pass title through events Test changes: - NEW: ToolUtilsTest, ToolSchemaTest, ToolResultBlockTest, ToolResultMessageBuilderTest (~36 test cases) - EXTEND: ToolUseBlockTest, ToolBaseTest, SchemaOnlyToolTest, AsyncToolMiddlewareTest (~14 additional test cases) --- .../java/io/agentscope/core/ReActAgent.java | 70 +++- .../accumulator/ToolCallsAccumulator.java | 6 + .../core/event/ToolCallEndEvent.java | 13 +- .../core/event/ToolCallStartEvent.java | 13 +- .../core/event/ToolResultEndEvent.java | 17 + .../core/event/ToolResultStartEvent.java | 13 +- .../core/hook/LegacyHookDispatcher.java | 8 +- .../core/message/ToolResultBlock.java | 165 ++++++++-- .../agentscope/core/message/ToolUseBlock.java | 99 +++++- .../io/agentscope/core/model/ToolSchema.java | 23 ++ .../io/agentscope/core/tool/AgentTool.java | 13 + .../core/tool/McpClientManager.java | 1 + .../agentscope/core/tool/SchemaOnlyTool.java | 32 +- .../io/agentscope/core/tool/ToolBase.java | 43 +++ .../core/tool/ToolResultMessageBuilder.java | 9 +- .../core/tool/ToolSchemaProvider.java | 1 + .../io/agentscope/core/tool/mcp/McpTool.java | 23 ++ .../io/agentscope/core/util/ToolUtils.java | 36 ++ .../core/message/ToolResultBlockTest.java | 310 ++++++++++++++++++ .../core/message/ToolUseBlockTest.java | 101 +++++- .../agentscope/core/model/ToolSchemaTest.java | 131 ++++++++ .../core/tool/SchemaOnlyToolTest.java | 74 +++++ .../io/agentscope/core/tool/ToolBaseTest.java | 63 ++++ .../tool/ToolResultMessageBuilderTest.java | 124 +++---- .../agentscope/core/util/ToolUtilsTest.java | 109 ++++++ .../agent/middleware/AsyncToolMiddleware.java | 10 +- .../agent/middleware/PlanModeMiddleware.java | 10 +- .../middleware/AsyncToolMiddlewareTest.java | 11 +- 28 files changed, 1408 insertions(+), 120 deletions(-) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/util/ToolUtils.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/message/ToolResultBlockTest.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/model/ToolSchemaTest.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/util/ToolUtilsTest.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index c0abbb9f6e..cd5e972de8 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -15,6 +15,8 @@ */ package io.agentscope.core; +import static io.agentscope.core.util.ToolUtils.resolveToolTitle; + import com.fasterxml.jackson.databind.JsonNode; import io.agentscope.core.agent.Agent; import io.agentscope.core.agent.AgentBase; @@ -2025,6 +2027,37 @@ private Mono runPostReasoningPipeline(Msg msg, int iter) { event -> { Msg eventMsg = event.getReasoningMessage(); if (eventMsg != null) { + if (eventMsg.hasContentBlocks(ToolUseBlock.class)) { + List newContent = + eventMsg.getContent().stream() + .map( + block -> + block + instanceof + ToolUseBlock + tub + ? tub.withTitle( + resolveToolTitle( + toolkit, + tub + .getName())) + : block) + .toList(); + Msg newEventMsg = + Msg.builder() + .id(eventMsg.getId()) + .name(eventMsg.getName()) + .role(eventMsg.getRole()) + .metadata(eventMsg.getMetadata()) + .generateReason( + eventMsg.getGenerateReason()) + .timestamp(eventMsg.getTimestamp()) + .usage(eventMsg.getUsage()) + .content(newContent) + .build(); + eventMsg = newEventMsg; + event.setReasoningMessage(newEventMsg); + } state.contextMutable().add(eventMsg); } @@ -2215,7 +2248,12 @@ private void startToolCall(String toolId, String toolName, List even flushAllToolCalls(events); boolean visibleTool = toolName != null && !toolName.startsWith("__"); if (visibleTool && startedToolCalls.putIfAbsent(toolId, toolName) == null) { - events.add(new ToolCallStartEvent(replyId, toolId, toolName)); + events.add( + new ToolCallStartEvent( + replyId, + toolId, + toolName, + resolveToolTitle(toolkit, toolName))); } } @@ -2233,7 +2271,12 @@ private void flushThinking(List events) { private void flushAllToolCalls(List events) { for (Map.Entry tc : startedToolCalls.entrySet()) { - events.add(new ToolCallEndEvent(replyId, tc.getKey(), tc.getValue())); + events.add( + new ToolCallEndEvent( + replyId, + tc.getKey(), + tc.getValue(), + resolveToolTitle(toolkit, tc.getValue()))); } startedToolCalls.clear(); } @@ -2481,7 +2524,10 @@ private Flux runToolBatch( ToolUseBlock use = entry.getKey(); return Flux.just( new ToolResultStartEvent( - replyId, use.getId(), use.getName()), + replyId, + use.getId(), + use.getName(), + resolveToolTitle(toolkit, use.getName())), new ToolResultTextDeltaEvent( replyId, use.getId(), @@ -2491,6 +2537,7 @@ private Flux runToolBatch( replyId, use.getId(), use.getName(), + resolveToolTitle(toolkit, use.getName()), ToolResultState.DENIED)); }); @@ -2515,7 +2562,10 @@ private Flux runToolBatch( new ToolResultStartEvent( replyId, tool.getId(), - tool.getName())); + tool.getName(), + resolveToolTitle( + toolkit, + tool.getName()))); } Set chunkedToolIds = @@ -2931,6 +2981,9 @@ private Mono notifyPostActingHook( ToolUseBlock toolUse = entry.getKey(); ToolResultBlock result = entry.getValue(); + ToolUseBlock newToolUse = + toolUse.withTitle(resolveToolTitle(toolkit, toolUse.getName())); + // FIX: determine the final state and update ToolResultBlock before // adding to contextMutable(), so that history queries via // agent.getAgentState(ctx).contextMutable() reflect the correct @@ -2939,17 +2992,18 @@ private Mono notifyPostActingHook( ToolResultBlock updatedResult = result.withState(finalState); Msg toolMsg = - ToolResultMessageBuilder.buildToolResultMsg(updatedResult, toolUse, getName()); + ToolResultMessageBuilder.buildToolResultMsg( + updatedResult, newToolUse, getName()); return hookDispatcher .firePostActing(toolUse, updatedResult, toolkit, toolMsg) .doOnNext( e -> { if (soTool != null - && STRUCTURED_OUTPUT_TOOL_NAME.equals(toolUse.getName()) - && result.getMetadata() != null + && STRUCTURED_OUTPUT_TOOL_NAME.equals(newToolUse.getName()) + && updatedResult.getMetadata() != null && Boolean.TRUE.equals( - result.getMetadata().get("success"))) { + updatedResult.getMetadata().get("success"))) { soCompleted = true; soResultMsg = e.getToolResultMsg(); e.stopAgent(); diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulator.java b/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulator.java index 75aa2cc907..009802d28d 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulator.java +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ToolCallsAccumulator.java @@ -52,6 +52,7 @@ public class ToolCallsAccumulator implements ContentAccumulator { private static class ToolCallBuilder { String toolId; String name; + String title; Map args = new HashMap<>(); StringBuilder rawContent = new StringBuilder(); Map metadata = new HashMap<>(); @@ -67,6 +68,10 @@ void merge(ToolUseBlock block) { this.name = block.getName(); } + if (block.getTitle() != null) { + this.title = block.getTitle(); + } + // Merge parameters if (block.getInput() != null) { this.args.putAll(block.getInput()); @@ -116,6 +121,7 @@ ToolUseBlock build() { return ToolUseBlock.builder() .id(toolId != null ? toolId : generateId()) .name(name) + .title(title) .input(finalArgs) .content(contentStr) .metadata(metadata.isEmpty() ? null : metadata) diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallEndEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallEndEvent.java index 69edd24f80..d6dc39ead1 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallEndEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallEndEvent.java @@ -23,6 +23,7 @@ public class ToolCallEndEvent extends AgentEvent { private final String replyId; private final String toolCallId; private final String toolCallName; + private final String toolCallTitle; @JsonCreator public ToolCallEndEvent( @@ -30,17 +31,21 @@ public ToolCallEndEvent( @JsonProperty("createdAt") String createdAt, @JsonProperty("replyId") String replyId, @JsonProperty("toolCallId") String toolCallId, - @JsonProperty("toolCallName") String toolCallName) { + @JsonProperty("toolCallName") String toolCallName, + @JsonProperty("toolCallTitle") String toolCallTitle) { super(id, createdAt); this.replyId = replyId; this.toolCallId = toolCallId; this.toolCallName = toolCallName; + this.toolCallTitle = toolCallTitle; } - public ToolCallEndEvent(String replyId, String toolCallId, String toolCallName) { + public ToolCallEndEvent( + String replyId, String toolCallId, String toolCallName, String toolCallTitle) { this.replyId = replyId; this.toolCallId = toolCallId; this.toolCallName = toolCallName; + this.toolCallTitle = toolCallTitle; } @Override @@ -59,4 +64,8 @@ public String getToolCallId() { public String getToolCallName() { return toolCallName; } + + public String getToolCallTitle() { + return toolCallTitle; + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallStartEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallStartEvent.java index b1f6ec4e9e..18b95cc293 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallStartEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallStartEvent.java @@ -23,6 +23,7 @@ public class ToolCallStartEvent extends AgentEvent { private final String replyId; private final String toolCallId; private final String toolCallName; + private final String toolCallTitle; @JsonCreator public ToolCallStartEvent( @@ -30,17 +31,21 @@ public ToolCallStartEvent( @JsonProperty("createdAt") String createdAt, @JsonProperty("replyId") String replyId, @JsonProperty("toolCallId") String toolCallId, - @JsonProperty("toolCallName") String toolCallName) { + @JsonProperty("toolCallName") String toolCallName, + @JsonProperty("toolCallTitle") String toolCallTitle) { super(id, createdAt); this.replyId = replyId; this.toolCallId = toolCallId; this.toolCallName = toolCallName; + this.toolCallTitle = toolCallTitle; } - public ToolCallStartEvent(String replyId, String toolCallId, String toolCallName) { + public ToolCallStartEvent( + String replyId, String toolCallId, String toolCallName, String toolCallTitle) { this.replyId = replyId; this.toolCallId = toolCallId; this.toolCallName = toolCallName; + this.toolCallTitle = toolCallTitle; } @Override @@ -59,4 +64,8 @@ public String getToolCallId() { public String getToolCallName() { return toolCallName; } + + public String getToolCallTitle() { + return toolCallTitle; + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultEndEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultEndEvent.java index f9321f56e3..abc4852ebe 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultEndEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultEndEvent.java @@ -24,6 +24,7 @@ public class ToolResultEndEvent extends AgentEvent { private final String replyId; private final String toolCallId; private final String toolCallName; + private final String toolCallTitle; private final ToolResultState state; @JsonCreator @@ -33,19 +34,31 @@ public ToolResultEndEvent( @JsonProperty("replyId") String replyId, @JsonProperty("toolCallId") String toolCallId, @JsonProperty("toolCallName") String toolCallName, + @JsonProperty("toolCallTitle") String toolCallTitle, @JsonProperty("state") ToolResultState state) { super(id, createdAt); this.replyId = replyId; this.toolCallId = toolCallId; this.toolCallName = toolCallName; + this.toolCallTitle = toolCallTitle; this.state = state; } public ToolResultEndEvent( String replyId, String toolCallId, String toolCallName, ToolResultState state) { + this(replyId, toolCallId, toolCallName, null, state); + } + + public ToolResultEndEvent( + String replyId, + String toolCallId, + String toolCallName, + String toolCallTitle, + ToolResultState state) { this.replyId = replyId; this.toolCallId = toolCallId; this.toolCallName = toolCallName; + this.toolCallTitle = toolCallTitle; this.state = state; } @@ -66,6 +79,10 @@ public String getToolCallName() { return toolCallName; } + public String getToolCallTitle() { + return toolCallTitle; + } + public ToolResultState getState() { return state; } diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultStartEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultStartEvent.java index 5378903f40..5e67008e0c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultStartEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultStartEvent.java @@ -23,6 +23,7 @@ public class ToolResultStartEvent extends AgentEvent { private final String replyId; private final String toolCallId; private final String toolCallName; + private final String toolCallTitle; @JsonCreator public ToolResultStartEvent( @@ -30,17 +31,21 @@ public ToolResultStartEvent( @JsonProperty("createdAt") String createdAt, @JsonProperty("replyId") String replyId, @JsonProperty("toolCallId") String toolCallId, - @JsonProperty("toolCallName") String toolCallName) { + @JsonProperty("toolCallName") String toolCallName, + @JsonProperty("toolCallTitle") String toolCallTitle) { super(id, createdAt); this.replyId = replyId; this.toolCallId = toolCallId; this.toolCallName = toolCallName; + this.toolCallTitle = toolCallTitle; } - public ToolResultStartEvent(String replyId, String toolCallId, String toolCallName) { + public ToolResultStartEvent( + String replyId, String toolCallId, String toolCallName, String toolCallTitle) { this.replyId = replyId; this.toolCallId = toolCallId; this.toolCallName = toolCallName; + this.toolCallTitle = toolCallTitle; } @Override @@ -59,4 +64,8 @@ public String getToolCallId() { public String getToolCallName() { return toolCallName; } + + public String getToolCallTitle() { + return toolCallTitle; + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/hook/LegacyHookDispatcher.java b/agentscope-core/src/main/java/io/agentscope/core/hook/LegacyHookDispatcher.java index fac6193769..a8127e377b 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/hook/LegacyHookDispatcher.java +++ b/agentscope-core/src/main/java/io/agentscope/core/hook/LegacyHookDispatcher.java @@ -15,6 +15,8 @@ */ package io.agentscope.core.hook; +import static io.agentscope.core.util.ToolUtils.resolveToolTitle; + import io.agentscope.core.ReActAgent; import io.agentscope.core.agent.accumulator.ReasoningContext; import io.agentscope.core.message.ContentBlock; @@ -83,7 +85,11 @@ public Mono fireReasoningChunk(Msg chunkMsg, ReasoningContext context, Str ThinkingBlock.builder().thinking(context.getAccumulatedThinking()).build(); } else if (content instanceof ToolUseBlock tub) { ToolUseBlock accumulated = context.getAccumulatedToolCall(tub.getId()); - accumulatedContent = accumulated != null ? accumulated : tub; + accumulatedContent = + accumulated != null + ? accumulated.withTitle( + resolveToolTitle(agent.getToolkit(), accumulated.getName())) + : tub.withTitle(resolveToolTitle(agent.getToolkit(), tub.getName())); } if (accumulatedContent != null) { diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java index 3f64596f22..27751b5346 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java @@ -28,7 +28,7 @@ * * This class serves two purposes: * 1. As a return value from tool methods (id and name are null) - * 2. As a ContentBlock in messages (id and name are required) + * 2. As a ContentBlock in messages (id and name and title are required) * * Supports metadata for passing additional execution information. */ @@ -39,6 +39,7 @@ public final class ToolResultBlock extends ContentBlock { private final String id; private final String name; + private final String title; private final List output; private final Map metadata; private final ToolResultState state; @@ -47,11 +48,13 @@ public final class ToolResultBlock extends ContentBlock { public ToolResultBlock( @JsonProperty("id") String id, @JsonProperty("name") String name, + @JsonProperty("title") String title, @JsonProperty("output") List output, @JsonProperty("metadata") Map metadata, @JsonProperty("state") ToolResultState state) { this.id = id; this.name = name; + this.title = title; this.output = output != null ? List.copyOf(output) : List.of(); this.metadata = metadata != null ? Map.copyOf(metadata) : Map.of(); this.state = state != null ? state : ToolResultState.RUNNING; @@ -59,29 +62,62 @@ public ToolResultBlock( public ToolResultBlock( String id, String name, List output, Map metadata) { - this(id, name, output, metadata, null); + this(id, name, null, output, metadata, null); + } + + public ToolResultBlock( + String id, + String name, + String title, + List output, + Map metadata) { + this(id, name, title, output, metadata, null); } /** * Creates a tool result block with a single content block output. * - * @param id Tool call ID - * @param name Tool name + * @param id Tool call ID + * @param name Tool name + * @param title Tool title + * @param output Single content block as output + */ + public ToolResultBlock(String id, String name, String title, ContentBlock output) { + this(id, name, title, List.of(output), null, null); + } + + /** + * Creates a tool result block with a single content block output. + * + * @param id Tool call ID + * @param name Tool name * @param output Single content block as output */ public ToolResultBlock(String id, String name, ContentBlock output) { - this(id, name, List.of(output), null, null); + this(id, name, null, List.of(output), null, null); } /** * Creates a tool result block with a list of content blocks as output. * - * @param id Tool call ID - * @param name Tool name + * @param id Tool call ID + * @param name Tool name + * @param title Tool title + * @param output List of content blocks as output + */ + public ToolResultBlock(String id, String name, String title, List output) { + this(id, name, title, output, null, null); + } + + /** + * Creates a tool result block with a list of content blocks as output. + * + * @param id Tool call ID + * @param name Tool name * @param output List of content blocks as output */ public ToolResultBlock(String id, String name, List output) { - this(id, name, output, null, null); + this(id, name, null, output, null, null); } /** @@ -102,6 +138,15 @@ public String getName() { return name; } + /** + * Gets the tool title. + * + * @return The tool title, or null if not set + */ + public String getTitle() { + return title; + } + /** * Gets the output content blocks. * @@ -136,7 +181,8 @@ public ToolResultState getState() { * @return A new ToolResultBlock with the updated state */ public ToolResultBlock withState(ToolResultState state) { - return new ToolResultBlock(this.id, this.name, this.output, this.metadata, state); + return new ToolResultBlock( + this.id, this.name, this.title, this.output, this.metadata, state); } /** @@ -171,6 +217,7 @@ public static ToolResultBlock suspended(ToolUseBlock toolUse, ToolSuspendExcepti return new ToolResultBlock( toolUse.getId(), toolUse.getName(), + toolUse.getTitle(), List.of(TextBlock.builder().text(content).build()), Map.of(METADATA_SUSPENDED, true)); } @@ -193,7 +240,7 @@ public static ToolResultBlock suspended(ToolUseBlock toolUse) { */ public static ToolResultBlock text(String text) { return new ToolResultBlock( - null, null, List.of(TextBlock.builder().text(text).build()), null); + null, null, null, List.of(TextBlock.builder().text(text).build()), null); } /** @@ -204,6 +251,7 @@ public static ToolResultBlock text(String text) { */ public static ToolResultBlock error(String errorMessage) { return new ToolResultBlock( + null, null, null, List.of(TextBlock.builder().text("Error: " + errorMessage).build()), @@ -217,7 +265,7 @@ public static ToolResultBlock error(String errorMessage) { * @return ToolResultBlock with the given output */ public static ToolResultBlock of(ContentBlock output) { - return new ToolResultBlock(null, null, List.of(output), null); + return new ToolResultBlock(null, null, null, List.of(output), null); } /** @@ -227,7 +275,7 @@ public static ToolResultBlock of(ContentBlock output) { * @return ToolResultBlock with the given output */ public static ToolResultBlock of(List output) { - return new ToolResultBlock(null, null, output, null); + return new ToolResultBlock(null, null, null, output, null); } /** @@ -238,7 +286,7 @@ public static ToolResultBlock of(List output) { * @return ToolResultBlock with output and metadata */ public static ToolResultBlock of(ContentBlock output, Map metadata) { - return new ToolResultBlock(null, null, List.of(output), metadata); + return new ToolResultBlock(null, null, null, List.of(output), metadata); } /** @@ -249,7 +297,7 @@ public static ToolResultBlock of(ContentBlock output, Map metada * @return ToolResultBlock with output and metadata */ public static ToolResultBlock of(List output, Map metadata) { - return new ToolResultBlock(null, null, output, metadata); + return new ToolResultBlock(null, null, null, output, metadata); } /** @@ -261,7 +309,34 @@ public static ToolResultBlock of(List output, Map * @return ToolResultBlock for use in messages */ public static ToolResultBlock of(String id, String name, ContentBlock output) { - return new ToolResultBlock(id, name, List.of(output), null); + return new ToolResultBlock(id, name, null, List.of(output), null); + } + + /** + * Create a result with id, name, and output (for message ContentBlock). + * + * @param id Tool call ID + * @param name Tool name + * @param title Tool title + * @param output Content block output + * @return ToolResultBlock for use in messages + */ + public static ToolResultBlock of(String id, String name, String title, ContentBlock output) { + return new ToolResultBlock(id, name, title, List.of(output), null); + } + + /** + * Create a result with id, name, and output list (for message ContentBlock). + * + * @param id Tool call ID + * @param name Tool name + * @param title Tool title + * @param output List of content blocks + * @return ToolResultBlock for use in messages + */ + public static ToolResultBlock of( + String id, String name, String title, List output) { + return new ToolResultBlock(id, name, title, output, null); } /** @@ -273,36 +348,46 @@ public static ToolResultBlock of(String id, String name, ContentBlock output) { * @return ToolResultBlock for use in messages */ public static ToolResultBlock of(String id, String name, List output) { - return new ToolResultBlock(id, name, output, null); + return new ToolResultBlock(id, name, null, output, null); } /** * Create a result with all fields (for message ContentBlock with metadata). * - * @param id Tool call ID - * @param name Tool name - * @param output Content block output + * @param id Tool call ID + * @param name Tool name + * @param title Tool title + * @param output Content block output * @param metadata Metadata map * @return ToolResultBlock with all fields */ public static ToolResultBlock of( - String id, String name, ContentBlock output, Map metadata) { - return new ToolResultBlock(id, name, List.of(output), metadata); + String id, + String name, + String title, + ContentBlock output, + Map metadata) { + return new ToolResultBlock(id, name, title, List.of(output), metadata); } /** * Create a result with all fields including output list (for message ContentBlock with * metadata). * - * @param id Tool call ID - * @param name Tool name - * @param output List of content blocks + * @param id Tool call ID + * @param name Tool name + * @param title Tool title + * @param output List of content blocks * @param metadata Metadata map * @return ToolResultBlock with all fields */ public static ToolResultBlock of( - String id, String name, List output, Map metadata) { - return new ToolResultBlock(id, name, output, metadata); + String id, + String name, + String title, + List output, + Map metadata) { + return new ToolResultBlock(id, name, title, output, metadata); } /** @@ -313,7 +398,19 @@ public static ToolResultBlock of( * @return New ToolResultBlock with id and name set */ public ToolResultBlock withIdAndName(String id, String name) { - return new ToolResultBlock(id, name, this.output, this.metadata, this.state); + return new ToolResultBlock(id, name, this.title, this.output, this.metadata, this.state); + } + + /** + * Create a ToolResultBlock for use in messages by setting id and name and title. + * + * @param id Tool call ID + * @param name Tool name + * @param title Tool title + * @return New ToolResultBlock with id and name set + */ + public ToolResultBlock withIdAndNameAndTitle(String id, String name, String title) { + return new ToolResultBlock(id, name, title, this.output, this.metadata, this.state); } /** @@ -331,6 +428,7 @@ public static Builder builder() { public static class Builder { private String id; private String name; + private String title; private List output; private Map metadata; private ToolResultState state; @@ -357,6 +455,17 @@ public Builder name(String name) { return this; } + /** + * Sets the tool title. + * + * @param title The tool title + * @return This builder for chaining + */ + public Builder title(String title) { + this.title = title; + return this; + } + /** * Sets a single content block as output. * @@ -407,7 +516,7 @@ public Builder state(ToolResultState state) { * @return A new ToolResultBlock instance */ public ToolResultBlock build() { - return new ToolResultBlock(id, name, output, metadata, state); + return new ToolResultBlock(id, name, title, output, metadata, state); } } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/ToolUseBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/ToolUseBlock.java index eb0fbe7fe9..436f0e8c20 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ToolUseBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ToolUseBlock.java @@ -25,7 +25,7 @@ * Represents a tool use request within a message. * *

This content block is used when an agent requests to execute a tool. - * It contains the tool's unique identifier, name, input parameters, and optionally + * It contains the tool's unique identifier, name, title, input parameters, and optionally * the raw content for streaming tool calls. * *

The tool input is stored as a generic map of string keys to object values, @@ -38,11 +38,30 @@ public final class ToolUseBlock extends ContentBlock { private final String id; private final String name; + private final String title; private final Map input; private final String content; // Raw content for streaming tool calls private final Map metadata; // Provider-specific metadata private final ToolCallState state; + /** + * Creates a new tool use block for JSON deserialization. + * + * @param id Unique identifier for this tool call + * @param name Name of the tool to execute + * @param title Title of the tool to execute + * @param input Input parameters for the tool (will be defensively copied) + * @param metadata Provider-specific metadata (will be defensively copied) + */ + public ToolUseBlock( + String id, + String name, + String title, + Map input, + Map metadata) { + this(id, name, title, input, null, metadata, null); + } + /** * Creates a new tool use block for JSON deserialization. * @@ -53,7 +72,7 @@ public final class ToolUseBlock extends ContentBlock { */ public ToolUseBlock( String id, String name, Map input, Map metadata) { - this(id, name, input, null, metadata, null); + this(id, name, null, input, null, metadata, null); } /** @@ -64,7 +83,39 @@ public ToolUseBlock( * @param input Input parameters for the tool (will be defensively copied) */ public ToolUseBlock(String id, String name, Map input) { - this(id, name, input, null, null, null); + this(id, name, null, input, null, null, null); + } + + /** + * Creates a new tool use block without metadata (convenience constructor). + * + * @param id Unique identifier for this tool call + * @param name Name of the tool to execute + * @param title Title of the tool to execute + * @param input Input parameters for the tool (will be defensively copied) + */ + public ToolUseBlock(String id, String name, String title, Map input) { + this(id, name, title, input, null, null, null); + } + + /** + * Creates a new tool use block with raw content for streaming. + * + * @param id Unique identifier for this tool call + * @param name Name of the tool to execute + * @param title Title of the tool to execute + * @param input Input parameters for the tool (will be defensively copied) + * @param content Raw content for streaming tool calls + * @param metadata Provider-specific metadata (will be defensively copied) + */ + public ToolUseBlock( + String id, + String name, + String title, + Map input, + String content, + Map metadata) { + this(id, name, title, input, content, metadata, null); } /** @@ -82,7 +133,7 @@ public ToolUseBlock( Map input, String content, Map metadata) { - this(id, name, input, content, metadata, null); + this(id, name, null, input, content, metadata, null); } /** @@ -90,6 +141,7 @@ public ToolUseBlock( * * @param id Unique identifier for this tool call * @param name Name of the tool to execute + * @param title Title of the tool to execute * @param input Input parameters for the tool (will be defensively copied) * @param content Raw content for streaming tool calls * @param metadata Provider-specific metadata (will be defensively copied) @@ -99,12 +151,14 @@ public ToolUseBlock( public ToolUseBlock( @JsonProperty("id") String id, @JsonProperty("name") String name, + @JsonProperty("title") String title, @JsonProperty("input") Map input, @JsonProperty("content") String content, @JsonProperty("metadata") Map metadata, @JsonProperty("state") ToolCallState state) { this.id = id; this.name = name; + this.title = title; // Defensive copy to prevent external modifications this.input = input == null @@ -136,6 +190,15 @@ public String getName() { return name; } + /** + * Gets the title of the tool to execute. + * + * @return The tool title + */ + public String getTitle() { + return title; + } + /** * Gets the input parameters for the tool. * @@ -182,7 +245,19 @@ public ToolCallState getState() { * @return A new ToolUseBlock with the updated state */ public ToolUseBlock withState(ToolCallState state) { - return new ToolUseBlock(this.id, this.name, this.input, this.content, this.metadata, state); + return new ToolUseBlock( + this.id, this.name, this.title, this.input, this.content, this.metadata, state); + } + + /** + * Returns a copy of this block with the given title. + * + * @param title The new title + * @return A new ToolUseBlock with the updated title + */ + public ToolUseBlock withTitle(String title) { + return new ToolUseBlock( + this.id, this.name, title, this.input, this.content, this.metadata, this.state); } /** @@ -200,6 +275,7 @@ public static Builder builder() { public static class Builder { private String id; private String name; + private String title; private Map input; private String content; private Map metadata; @@ -227,6 +303,17 @@ public Builder name(String name) { return this; } + /** + * Sets the title of the tool to execute. + * + * @param title The tool title + * @return This builder for chaining + */ + public Builder title(String title) { + this.title = title; + return this; + } + /** * Sets the input parameters for the tool. * @@ -280,7 +367,7 @@ public Builder state(ToolCallState state) { * @return A new ToolUseBlock instance */ public ToolUseBlock build() { - return new ToolUseBlock(id, name, input, content, metadata, state); + return new ToolUseBlock(id, name, title, input, content, metadata, state); } } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ToolSchema.java b/agentscope-core/src/main/java/io/agentscope/core/model/ToolSchema.java index 3dcf3a06df..34e46238ea 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/ToolSchema.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/ToolSchema.java @@ -27,6 +27,7 @@ */ public class ToolSchema { private final String name; + private final String title; private final String description; private final Map parameters; private final Map outputSchema; @@ -39,6 +40,7 @@ public class ToolSchema { */ private ToolSchema(Builder builder) { this.name = Objects.requireNonNull(builder.name, "name is required"); + this.title = builder.title; this.description = Objects.requireNonNull(builder.description, "description is required"); this.parameters = builder.parameters != null @@ -60,6 +62,15 @@ public String getName() { return name; } + /** + * Gets the optional human-readable tool title. + * + * @return the tool title, or null if no title is defined + */ + public String getTitle() { + return title; + } + /** * Gets the tool description. * @@ -110,6 +121,7 @@ public static Builder builder() { */ public static class Builder { private String name; + private String title; private String description; private Map parameters; private Map outputSchema; @@ -126,6 +138,17 @@ public Builder name(String name) { return this; } + /** + * Sets the optional human-readable tool title. + * + * @param title the tool title + * @return this builder instance + */ + public Builder title(String title) { + this.title = title; + return this; + } + /** * Sets the tool description. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/AgentTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/AgentTool.java index f4dfa1b154..c5590953b1 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/AgentTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/AgentTool.java @@ -49,6 +49,19 @@ public interface AgentTool { */ String getName(); + /** + * Gets the title of the tool. + * + *

Intended for UI and end-user contexts — optimized to be human-readable and easily + * understood, even by those unfamiliar with domain-specific terminology. If not + * provided, the name should be used for display. + * + * @return The tool title + */ + default String getTitle() { + return getName(); + } + /** * Gets the description of the tool. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/McpClientManager.java b/agentscope-core/src/main/java/io/agentscope/core/tool/McpClientManager.java index bd300397b9..b2fc14caf5 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/McpClientManager.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/McpClientManager.java @@ -175,6 +175,7 @@ Mono registerMcpClient( McpTool agentTool = new McpTool( mcpTool.name(), + mcpTool.title(), mcpTool.description() != null ? mcpTool.description() : "", diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/SchemaOnlyTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/SchemaOnlyTool.java index cf656749b6..e934c86c33 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/SchemaOnlyTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/SchemaOnlyTool.java @@ -38,6 +38,7 @@ *

{@code
  * ToolSchema schema = ToolSchema.builder()
  *     .name("query_database")
+ *     .title("Query Database")
  *     .description("Query external database")
  *     .parameters(Map.of(
  *         "type", "object",
@@ -67,6 +68,7 @@ public class SchemaOnlyTool extends ToolBase {
     public SchemaOnlyTool(ToolSchema schema) {
         this(
                 Objects.requireNonNull(schema, "schema cannot be null").getName(),
+                schema.getTitle(),
                 schema.getDescription(),
                 schema.getParameters(),
                 schema.getStrict());
@@ -93,17 +95,41 @@ public SchemaOnlyTool(String name, String description, Map param
      * 

The parameters map is defensively copied to prevent external mutations from affecting * the tool's internal state. * - * @param name The tool name + * @param name The tool name * @param description The tool description - * @param parameters The tool parameters schema - * @param strict Whether the tool should use strict schema validation (null if unspecified) + * @param parameters The tool parameters schema + * @param strict Whether the tool should use strict schema validation (null if unspecified) * @throws NullPointerException if name or description is null */ public SchemaOnlyTool( String name, String description, Map parameters, Boolean strict) { + this(name, null, description, parameters, strict); + } + + /** + * Creates a new SchemaOnlyTool with the specified name, title, description, parameters, and strict + * mode configuration. + * + *

The parameters map is defensively copied to prevent external mutations from affecting + * the tool's internal state. + * + * @param name The tool name + * @param title The tool title + * @param description The tool description + * @param parameters The tool parameters schema + * @param strict Whether the tool should use strict schema validation (null if unspecified) + * @throws NullPointerException if name or description is null + */ + public SchemaOnlyTool( + String name, + String title, + String description, + Map parameters, + Boolean strict) { super( ToolBase.builder() .name(Objects.requireNonNull(name, "name cannot be null")) + .title(title) .description( Objects.requireNonNull(description, "description cannot be null")) .inputSchema( diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java index 7334661b5f..2a6b35d998 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java @@ -50,6 +50,7 @@ *

{@code
  * super(ToolBase.builder()
  *         .name("read")
+ *         .title("Read a file")
  *         .description("Read a file")
  *         .inputSchema(schema)
  *         .readOnly(true)
@@ -60,6 +61,7 @@
 public abstract class ToolBase implements AgentTool {
 
     private final String name;
+    private final String title;
     private final String description;
     private final Map inputSchema;
     private final boolean concurrencySafe;
@@ -80,6 +82,7 @@ public abstract class ToolBase implements AgentTool {
     protected ToolBase(Builder builder) {
         this(
                 builder.name,
+                builder.title,
                 builder.description,
                 builder.inputSchema,
                 builder.readOnly,
@@ -102,6 +105,7 @@ protected ToolBase(Builder builder) {
      */
     protected ToolBase(
             String name,
+            String title,
             String description,
             Map inputSchema,
             boolean readOnly,
@@ -111,6 +115,7 @@ protected ToolBase(
             boolean externalTool,
             boolean stateInjected) {
         this.name = Objects.requireNonNull(name, "name must not be null");
+        this.title = title;
         this.description = Objects.requireNonNull(description, "description must not be null");
         this.inputSchema = Objects.requireNonNull(inputSchema, "inputSchema must not be null");
         this.readOnly = readOnly;
@@ -124,11 +129,43 @@ protected ToolBase(
         }
     }
 
+    /**
+     * Positional constructor used by built-in tools and any subclass that prefers explicit
+     * arguments over {@link #builder()}. New code is encouraged to use the builder for clarity.
+     */
+    protected ToolBase(
+            String name,
+            String description,
+            Map inputSchema,
+            boolean readOnly,
+            boolean concurrencySafe,
+            boolean mcp,
+            String mcpName,
+            boolean externalTool,
+            boolean stateInjected) {
+        this(
+                name,
+                null,
+                description,
+                inputSchema,
+                readOnly,
+                concurrencySafe,
+                mcp,
+                mcpName,
+                externalTool,
+                stateInjected);
+    }
+
     @Override
     public final String getName() {
         return name;
     }
 
+    @Override
+    public final String getTitle() {
+        return title;
+    }
+
     @Override
     public final String getDescription() {
         return description;
@@ -273,6 +310,7 @@ public static Builder builder() {
     /** Fluent builder for {@link ToolBase} subclasses. */
     public static final class Builder {
         private String name;
+        private String title;
         private String description;
         private Map inputSchema;
         private boolean readOnly = false;
@@ -291,6 +329,11 @@ public Builder name(String name) {
             return this;
         }
 
+        public Builder title(String title) {
+            this.title = title;
+            return this;
+        }
+
         public Builder description(String description) {
             this.description = description;
             return this;
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolResultMessageBuilder.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolResultMessageBuilder.java
index a9bba7039c..b0181bebe6 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolResultMessageBuilder.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolResultMessageBuilder.java
@@ -39,14 +39,15 @@ public class ToolResultMessageBuilder {
      */
     public static Msg buildToolResultMsg(
             ToolResultBlock result, ToolUseBlock originalCall, String agentName) {
-        // Set id and name from original call
-        ToolResultBlock resultWithIdAndName =
-                result.withIdAndName(originalCall.getId(), originalCall.getName());
+        // Set id and name and title from original call
+        ToolResultBlock resultWithIdAndNameAndTitle =
+                result.withIdAndNameAndTitle(
+                        originalCall.getId(), originalCall.getName(), originalCall.getTitle());
 
         return Msg.builder()
                 .name(agentName)
                 .role(MsgRole.TOOL)
-                .content(resultWithIdAndName)
+                .content(resultWithIdAndNameAndTitle)
                 .build();
     }
 }
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolSchemaProvider.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolSchemaProvider.java
index 40ac9e0e0e..e0cf71cc45 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolSchemaProvider.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolSchemaProvider.java
@@ -97,6 +97,7 @@ private List buildSchemas(Set activeTools) {
             ToolSchema schema =
                     ToolSchema.builder()
                             .name(toolName)
+                            .title(tool.getTitle())
                             .description(tool.getDescription())
                             .parameters(registered.getExtendedParameters())
                             .strict(tool.getStrict())
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpTool.java
index b7b3a9ec49..d0db29af69 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpTool.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpTool.java
@@ -59,6 +59,7 @@ public class McpTool extends ToolBase {
     /** Preferred constructor used by {@link io.agentscope.core.tool.McpClientManager}. */
     public McpTool(
             String name,
+            String title,
             String description,
             Map parameters,
             Map outputSchema,
@@ -69,6 +70,7 @@ public McpTool(
         super(
                 ToolBase.builder()
                         .name(Objects.requireNonNull(name, "name cannot be null"))
+                        .title(title)
                         .description(description != null ? description : "")
                         .inputSchema(parameters != null ? parameters : new HashMap<>())
                         .readOnly(readOnly)
@@ -79,6 +81,27 @@ public McpTool(
         this.presetArguments = presetArguments != null ? new HashMap<>(presetArguments) : null;
     }
 
+    public McpTool(
+            String name,
+            String description,
+            Map parameters,
+            Map outputSchema,
+            McpClientWrapper clientWrapper,
+            Map presetArguments,
+            String mcpName,
+            boolean readOnly) {
+        this(
+                name,
+                null,
+                description,
+                parameters,
+                outputSchema,
+                clientWrapper,
+                presetArguments,
+                mcpName,
+                readOnly);
+    }
+
     /**
      * @deprecated Use {@link #McpTool(String, String, Map, Map, McpClientWrapper, Map, String,
      *     boolean)} so the MCP server name and read-only hint are propagated. Kept for source
diff --git a/agentscope-core/src/main/java/io/agentscope/core/util/ToolUtils.java b/agentscope-core/src/main/java/io/agentscope/core/util/ToolUtils.java
new file mode 100644
index 0000000000..bd62c33e09
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/util/ToolUtils.java
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+package io.agentscope.core.util;
+
+import io.agentscope.core.tool.AgentTool;
+import io.agentscope.core.tool.Toolkit;
+
+/**
+ * Utility class for toolkit.
+ */
+public class ToolUtils {
+
+    public static String resolveToolTitle(Toolkit toolkit, String toolName) {
+        if (toolName == null) {
+            return null;
+        }
+        if (toolkit == null) {
+            return null;
+        }
+        AgentTool tool = toolkit.getTool(toolName);
+        return tool != null ? tool.getTitle() : null;
+    }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/message/ToolResultBlockTest.java b/agentscope-core/src/test/java/io/agentscope/core/message/ToolResultBlockTest.java
new file mode 100644
index 0000000000..053891be0a
--- /dev/null
+++ b/agentscope-core/src/test/java/io/agentscope/core/message/ToolResultBlockTest.java
@@ -0,0 +1,310 @@
+/*
+ * 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.
+ */
+package io.agentscope.core.message;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link ToolResultBlock}, focusing on title field and related methods.
+ */
+@Tag("unit")
+@DisplayName("ToolResultBlock Unit Tests")
+class ToolResultBlockTest {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @Test
+    @DisplayName("Should create block with id, name, title via constructor")
+    void testConstructorWithTitle() {
+        ToolResultBlock block =
+                new ToolResultBlock("call-1", "search_tool", "Search Tool", List.of(), null);
+
+        assertEquals("call-1", block.getId());
+        assertEquals("search_tool", block.getName());
+        assertEquals("Search Tool", block.getTitle());
+        assertTrue(block.getOutput().isEmpty());
+        assertTrue(block.getMetadata().isEmpty());
+    }
+
+    @Test
+    @DisplayName("Should create block with title and metadata")
+    void testConstructorWithTitleAndMetadata() {
+        ToolResultBlock block =
+                new ToolResultBlock(
+                        "call-2",
+                        "calc_tool",
+                        "Calculator",
+                        List.of(TextBlock.builder().text("result: 42").build()),
+                        Map.of("duration_ms", 150));
+
+        assertEquals("call-2", block.getId());
+        assertEquals("calc_tool", block.getName());
+        assertEquals("Calculator", block.getTitle());
+        assertEquals(1, block.getOutput().size());
+        assertEquals("result: 42", ((TextBlock) block.getOutput().get(0)).getText());
+        assertEquals(150, block.getMetadata().get("duration_ms"));
+    }
+
+    @Test
+    @DisplayName("Should create block with title and single output")
+    void testConstructorWithTitleAndSingleOutput() {
+        ToolResultBlock block =
+                new ToolResultBlock(
+                        "call-3",
+                        "fetch_tool",
+                        "Fetch Data",
+                        TextBlock.builder().text("data").build());
+
+        assertEquals("call-3", block.getId());
+        assertEquals("fetch_tool", block.getName());
+        assertEquals("Fetch Data", block.getTitle());
+        assertEquals(1, block.getOutput().size());
+        assertEquals("data", ((TextBlock) block.getOutput().get(0)).getText());
+    }
+
+    @Test
+    @DisplayName("Should use RUNNING state by default")
+    void testDefaultStateIsRunning() {
+        ToolResultBlock block =
+                new ToolResultBlock(
+                        "call-4", "tool", "Tool", TextBlock.builder().text("test").build());
+
+        assertEquals(ToolResultState.RUNNING, block.getState());
+    }
+
+    @Test
+    @DisplayName("Should propagate title through withIdAndNameAndTitle")
+    void testWithIdAndNameAndTitle() {
+        ToolResultBlock original =
+                ToolResultBlock.of(
+                        "result", "original_tool", TextBlock.builder().text("ok").build());
+
+        ToolResultBlock updated = original.withIdAndNameAndTitle("new-id", "new_name", "New Title");
+
+        assertEquals("new-id", updated.getId());
+        assertEquals("new_name", updated.getName());
+        assertEquals("New Title", updated.getTitle());
+    }
+
+    @Test
+    @DisplayName("Should preserve output when using withIdAndNameAndTitle")
+    void testWithIdAndNameAndTitlePreservesOutput() {
+        ToolResultBlock original =
+                ToolResultBlock.of(
+                        "orig", "tool", List.of(TextBlock.builder().text("hello").build()));
+
+        ToolResultBlock updated = original.withIdAndNameAndTitle("new-id", "new_name", "Title");
+
+        assertEquals(1, updated.getOutput().size());
+        assertEquals("hello", ((TextBlock) updated.getOutput().get(0)).getText());
+        assertEquals(ToolResultState.RUNNING, updated.getState());
+    }
+
+    @Test
+    @DisplayName("Should propagate title through withState")
+    void testWithStatePreservesTitle() {
+        ToolResultBlock block =
+                new ToolResultBlock(
+                        "call-5", "tool", "My Title", TextBlock.builder().text("test").build());
+
+        ToolResultBlock successBlock = block.withState(ToolResultState.SUCCESS);
+
+        assertEquals("call-5", successBlock.getId());
+        assertEquals("tool", successBlock.getName());
+        assertEquals("My Title", successBlock.getTitle());
+        assertEquals(ToolResultState.SUCCESS, successBlock.getState());
+    }
+
+    @Test
+    @DisplayName("Should serialize and deserialize title via JSON")
+    void testJsonSerializationWithTitle() throws JsonProcessingException {
+        ToolResultBlock block =
+                new ToolResultBlock(
+                        "call-6",
+                        "data_tool",
+                        "Data Tool",
+                        List.of(TextBlock.builder().text("output text").build()),
+                        null);
+
+        String json = objectMapper.writeValueAsString(block);
+        assertTrue(json.contains("\"title\":\"Data Tool\""));
+        assertTrue(json.contains("\"id\":\"call-6\""));
+        assertTrue(json.contains("\"name\":\"data_tool\""));
+    }
+
+    @Test
+    @DisplayName("Should deserialize title from JSON")
+    void testJsonDeserializationWithTitle() throws JsonProcessingException {
+        String json =
+                """
+                {
+                    "type": "tool_result",
+                    "id": "call-7",
+                    "name": "search_tool",
+                    "title": "Search Engine",
+                    "output": [{"type": "text", "text": "results"}],
+                    "state": "success"
+                }
+                """;
+
+        ToolResultBlock block = objectMapper.readValue(json, ToolResultBlock.class);
+
+        assertEquals("call-7", block.getId());
+        assertEquals("search_tool", block.getName());
+        assertEquals("Search Engine", block.getTitle());
+        assertEquals(1, block.getOutput().size());
+        assertEquals(ToolResultState.SUCCESS, block.getState());
+    }
+
+    @Test
+    @DisplayName("Should handle null title in JSON")
+    void testJsonDeserializationWithNullTitle() throws JsonProcessingException {
+        String json =
+                """
+                {
+                    "type": "tool_result",
+                    "id": "call-8",
+                    "name": "tool",
+                    "title": null,
+                    "output": [],
+                    "state": "running"
+                }
+                """;
+
+        ToolResultBlock block = objectMapper.readValue(json, ToolResultBlock.class);
+
+        assertEquals("call-8", block.getId());
+        assertEquals("tool", block.getName());
+        assertNull(block.getTitle());
+    }
+
+    @Test
+    @DisplayName("Should handle missing title in JSON gracefully")
+    void testJsonDeserializationWithoutTitle() throws JsonProcessingException {
+        String json =
+                """
+                {
+                    "type": "tool_result",
+                    "id": "call-9",
+                    "name": "tool",
+                    "output": []
+                }
+                """;
+
+        ToolResultBlock block = objectMapper.readValue(json, ToolResultBlock.class);
+
+        assertEquals("call-9", block.getId());
+        assertEquals("tool", block.getName());
+        assertNull(block.getTitle());
+    }
+
+    @Test
+    @DisplayName("Should have immutable output list")
+    void testOutputImmutability() {
+        ToolResultBlock block =
+                new ToolResultBlock(
+                        "id", "tool", "Title", TextBlock.builder().text("test").build());
+
+        assertThrows(
+                UnsupportedOperationException.class,
+                () -> block.getOutput().add(TextBlock.builder().text("more").build()));
+    }
+
+    @Test
+    @DisplayName("Builder should set title correctly")
+    void testBuilderWithTitle() {
+        ToolResultBlock block =
+                ToolResultBlock.builder()
+                        .id("bid-1")
+                        .name("b_tool")
+                        .title("Builder Tool")
+                        .output(TextBlock.builder().text("built").build())
+                        .state(ToolResultState.SUCCESS)
+                        .build();
+
+        assertEquals("bid-1", block.getId());
+        assertEquals("b_tool", block.getName());
+        assertEquals("Builder Tool", block.getTitle());
+        assertEquals("built", ((TextBlock) block.getOutput().get(0)).getText());
+        assertEquals(ToolResultState.SUCCESS, block.getState());
+    }
+
+    @Test
+    @DisplayName("Builder should handle null title")
+    void testBuilderWithNullTitle() {
+        ToolResultBlock block =
+                ToolResultBlock.builder()
+                        .id("id")
+                        .name("tool")
+                        .output(TextBlock.builder().text("test").build())
+                        .build();
+
+        assertNull(block.getTitle());
+    }
+
+    @Test
+    @DisplayName("of() factory should not set title")
+    void testOfFactoryDoesNotSetTitle() {
+        ToolResultBlock block =
+                ToolResultBlock.of("id", "tool", TextBlock.builder().text("result").build());
+
+        assertNull(block.getTitle());
+    }
+
+    @Test
+    @DisplayName("of() factory with list should not set title")
+    void testOfFactoryWithListDoesNotSetTitle() {
+        ToolResultBlock block =
+                ToolResultBlock.of(
+                        "id", "tool", List.of(TextBlock.builder().text("result").build()));
+
+        assertNull(block.getTitle());
+    }
+
+    @Test
+    @DisplayName("suspended() should create block with title from ToolUseBlock")
+    void testSuspendedWithTitle() {
+        ToolUseBlock toolUse =
+                ToolUseBlock.builder()
+                        .id("sid-1")
+                        .name("external_tool")
+                        .title("External Tool")
+                        .input(Map.of("key", "value"))
+                        .build();
+
+        ToolResultBlock suspended = ToolResultBlock.suspended(toolUse);
+
+        assertEquals("sid-1", suspended.getId());
+        assertEquals("external_tool", suspended.getName());
+        assertEquals("External Tool", suspended.getTitle());
+        assertEquals(1, suspended.getOutput().size());
+        assertEquals(
+                "[Awaiting external execution]",
+                ((TextBlock) suspended.getOutput().get(0)).getText());
+        assertTrue(suspended.isSuspended());
+    }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/message/ToolUseBlockTest.java b/agentscope-core/src/test/java/io/agentscope/core/message/ToolUseBlockTest.java
index f91a51958e..1265770e98 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/message/ToolUseBlockTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/message/ToolUseBlockTest.java
@@ -18,11 +18,13 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import java.util.Map;
+import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 
 /**
@@ -285,7 +287,7 @@ void testBuilderPattern() {
 
     @Test
     void testEmptyMapsForNullInputAndMetadata() {
-        ToolUseBlock toolUseBlock = new ToolUseBlock("tool-999", "null-test", null, null, null);
+        ToolUseBlock toolUseBlock = new ToolUseBlock("tool-999", "null-test", "null", null, null);
 
         assertNotNull(toolUseBlock.getInput());
         assertTrue(toolUseBlock.getInput().isEmpty());
@@ -293,4 +295,101 @@ void testEmptyMapsForNullInputAndMetadata() {
         assertTrue(toolUseBlock.getMetadata().isEmpty());
         assertEquals(null, toolUseBlock.getContent());
     }
+
+    // --- Title-related tests ---
+
+    @Test
+    @DisplayName("Should include title in JSON serialization")
+    void testJsonSerializationWithTitle() throws JsonProcessingException {
+        ToolUseBlock block =
+                ToolUseBlock.builder()
+                        .id("tool-title-1")
+                        .name("search_tool")
+                        .title("Search Engine")
+                        .input(Map.of("q", "hello"))
+                        .build();
+
+        String json = objectMapper.writeValueAsString(block);
+        assertTrue(json.contains("\"title\":\"Search Engine\""));
+    }
+
+    @Test
+    @DisplayName("Should deserialize title from JSON")
+    void testJsonDeserializationWithTitle() throws JsonProcessingException {
+        String json =
+                """
+                {
+                    "type": "tool_use",
+                    "id": "tool-title-2",
+                    "name": "calculator",
+                    "title": "Advanced Calc",
+                    "input": {"x": 1, "y": 2}
+                }
+                """;
+
+        ToolUseBlock block = objectMapper.readValue(json, ToolUseBlock.class);
+        assertEquals("tool-title-2", block.getId());
+        assertEquals("calculator", block.getName());
+        assertEquals("Advanced Calc", block.getTitle());
+    }
+
+    @Test
+    @DisplayName("Should return null title when not set")
+    void testTitleNullByDefault() {
+        ToolUseBlock block =
+                ToolUseBlock.builder().id("tool-no-title").name("simple").input(Map.of()).build();
+
+        assertNull(block.getTitle());
+    }
+
+    @Test
+    @DisplayName("withTitle should return new block with updated title")
+    void testWithTitle() {
+        ToolUseBlock original =
+                new ToolUseBlock("tool-1", "weather", Map.of("city", "Paris"), null);
+
+        ToolUseBlock updated = original.withTitle("Weather API");
+
+        assertEquals("weather", updated.getName());
+        assertEquals("Weather API", updated.getTitle());
+        // original unchanged
+        assertNull(original.getTitle());
+    }
+
+    @Test
+    @DisplayName("Builder should set title correctly")
+    void testBuilderWithTitle() {
+        ToolUseBlock block =
+                ToolUseBlock.builder()
+                        .id("tool-builder-title")
+                        .name("data_fetch")
+                        .title("Data Fetcher")
+                        .input(Map.of("url", "https://example.com"))
+                        .build();
+
+        assertEquals("data_fetch", block.getName());
+        assertEquals("Data Fetcher", block.getTitle());
+    }
+
+    @Test
+    @DisplayName("Constructor should accept title parameter")
+    void testConstructorWithTitle() {
+        ToolUseBlock block =
+                new ToolUseBlock(
+                        "tool-3", "query_db", "Query Database", Map.of("sql", "SELECT 1"), null);
+
+        assertEquals("tool-3", block.getId());
+        assertEquals("query_db", block.getName());
+        assertEquals("Query Database", block.getTitle());
+        assertEquals("SELECT 1", block.getInput().get("sql"));
+    }
+
+    @Test
+    @DisplayName("Backward-compatible constructor should set null title")
+    void testBackwardCompatibleConstructor() {
+        ToolUseBlock block = new ToolUseBlock("tool-4", "legacy_tool", Map.of(), null);
+
+        assertEquals("legacy_tool", block.getName());
+        assertNull(block.getTitle());
+    }
 }
diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/ToolSchemaTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/ToolSchemaTest.java
new file mode 100644
index 0000000000..5b9319a2d7
--- /dev/null
+++ b/agentscope-core/src/test/java/io/agentscope/core/model/ToolSchemaTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.
+ */
+package io.agentscope.core.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+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 java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link ToolSchema}.
+ */
+@Tag("unit")
+@DisplayName("ToolSchema Unit Tests")
+class ToolSchemaTest {
+
+    @Test
+    @DisplayName("Should build schema with all fields including title")
+    void testBuildWithTitle() {
+        ToolSchema schema =
+                ToolSchema.builder()
+                        .name("calculator")
+                        .title("Advanced Calculator")
+                        .description("A powerful calculator tool")
+                        .parameters(Map.of("type", "object"))
+                        .strict(true)
+                        .build();
+
+        assertEquals("calculator", schema.getName());
+        assertEquals("Advanced Calculator", schema.getTitle());
+        assertEquals("A powerful calculator tool", schema.getDescription());
+        assertNotNull(schema.getParameters());
+        assertEquals(Boolean.TRUE, schema.getStrict());
+    }
+
+    @Test
+    @DisplayName("Should return null title when not set")
+    void testTitleNullByDefault() {
+        ToolSchema schema =
+                ToolSchema.builder().name("simple_tool").description("Simple tool").build();
+
+        assertEquals("simple_tool", schema.getName());
+        assertNull(schema.getTitle());
+    }
+
+    @Test
+    @DisplayName("Should handle empty title")
+    void testEmptyTitle() {
+        ToolSchema schema = ToolSchema.builder().name("tool").title("").description("desc").build();
+
+        assertEquals("", schema.getTitle());
+    }
+
+    @Test
+    @DisplayName("Should require name")
+    void testRequireName() {
+        assertThrows(
+                NullPointerException.class, () -> ToolSchema.builder().description("desc").build());
+    }
+
+    @Test
+    @DisplayName("Should require description")
+    void testRequireDescription() {
+        assertThrows(NullPointerException.class, () -> ToolSchema.builder().name("tool").build());
+    }
+
+    @Test
+    @DisplayName("Should have immutable parameters")
+    void testParametersImmutability() {
+        ToolSchema schema =
+                ToolSchema.builder()
+                        .name("tool")
+                        .description("desc")
+                        .parameters(
+                                Map.of(
+                                        "type",
+                                        "object",
+                                        "properties",
+                                        Map.of("key", Map.of("type", "string")),
+                                        "required",
+                                        List.of("key")))
+                        .build();
+
+        assertThrows(
+                UnsupportedOperationException.class,
+                () -> schema.getParameters().put("extra", "value"));
+    }
+
+    @Test
+    @DisplayName("Should have title distinct from name")
+    void testTitleDistinctFromName() {
+        ToolSchema schema =
+                ToolSchema.builder()
+                        .name("query_users_db")
+                        .title("Query Users")
+                        .description("Query the users database")
+                        .build();
+
+        assertEquals("query_users_db", schema.getName());
+        assertEquals("Query Users", schema.getTitle());
+        assertNotEquals(schema.getName(), schema.getTitle());
+    }
+
+    @Test
+    @DisplayName("Should handle null strict mode")
+    void testNullStrictMode() {
+        ToolSchema schema = ToolSchema.builder().name("tool").description("desc").build();
+
+        assertNull(schema.getStrict());
+    }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/SchemaOnlyToolTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/SchemaOnlyToolTest.java
index d46127c3b4..45b447b48a 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/tool/SchemaOnlyToolTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/SchemaOnlyToolTest.java
@@ -192,4 +192,78 @@ void testStrictModePreservedInThreeArgConstructor() {
         // Verify that 3-arg constructor sets strict to null (unspecified)
         assertNull(tool.getStrict());
     }
+
+    // --- Title-related tests ---
+
+    @Test
+    @DisplayName("Should propagate title from ToolSchema to SchemaOnlyTool")
+    void testTitleFromToolSchema() {
+        ToolSchema schema =
+                ToolSchema.builder()
+                        .name("query_database")
+                        .title("Query Database")
+                        .description("Query an external database")
+                        .parameters(
+                                Map.of(
+                                        "type",
+                                        "object",
+                                        "properties",
+                                        Map.of("sql", Map.of("type", "string")),
+                                        "required",
+                                        List.of("sql")))
+                        .build();
+
+        SchemaOnlyTool tool = new SchemaOnlyTool(schema);
+        assertEquals("query_database", tool.getName());
+        assertEquals("Query Database", tool.getTitle());
+    }
+
+    @Test
+    @DisplayName("Should handle null title from ToolSchema")
+    void testNullTitleFromToolSchema() {
+        ToolSchema schema =
+                ToolSchema.builder()
+                        .name("no_title_tool")
+                        .description("Tool without title")
+                        .parameters(Map.of("type", "object"))
+                        .build();
+
+        SchemaOnlyTool tool = new SchemaOnlyTool(schema);
+        assertEquals("no_title_tool", tool.getName());
+        assertNull(tool.getTitle());
+    }
+
+    @Test
+    @DisplayName(
+            "Should propagate title through 5-arg constructor (name, title, desc, params, strict)")
+    void testTitleFromFiveArgConstructor() {
+        Map params = Map.of("type", "object");
+        SchemaOnlyTool tool =
+                new SchemaOnlyTool("title_tool", "Title Tool", "A tool with title", params, null);
+
+        assertEquals("title_tool", tool.getName());
+        assertEquals("Title Tool", tool.getTitle());
+        assertEquals("A tool with title", tool.getDescription());
+    }
+
+    @Test
+    @DisplayName("Should keep title null via 4-arg constructor (name, desc, params, strict)")
+    void testTitleFromFourArgConstructor() {
+        Map params = Map.of("type", "object");
+        SchemaOnlyTool tool = new SchemaOnlyTool("simple_tool", "Simple tool", params, null);
+
+        assertEquals("simple_tool", tool.getName());
+        assertNull(tool.getTitle());
+        assertEquals("Simple tool", tool.getDescription());
+    }
+
+    @Test
+    @DisplayName("Should keep title null via 3-arg constructor (name, desc, params)")
+    void testNullTitleFromThreeArgConstructor() {
+        Map params = Map.of("type", "object");
+        SchemaOnlyTool tool = new SchemaOnlyTool("simple_tool", "Simple tool", params);
+
+        assertEquals("simple_tool", tool.getName());
+        assertNull(tool.getTitle());
+    }
 }
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolBaseTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolBaseTest.java
index 5ca25a9a4f..557babb66d 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolBaseTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolBaseTest.java
@@ -31,6 +31,7 @@
 import io.agentscope.core.permission.PermissionRule;
 import java.util.List;
 import java.util.Map;
+import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 import reactor.core.publisher.Mono;
 import reactor.test.StepVerifier;
@@ -262,4 +263,66 @@ void isDangerousPathHandlesNullAndBlank() {
         assertFalse(probe.check(""));
         assertFalse(probe.check("   "));
     }
+
+    // --- Title-related tests ---
+
+    @Test
+    @DisplayName("Builder should accept and return custom title")
+    void testBuilderWithTitle() {
+        ToolBase tool =
+                new ToolBase(
+                        ToolBase.builder()
+                                .name("titled_tool")
+                                .title("My Custom Title")
+                                .description("desc")
+                                .inputSchema(emptySchema())) {
+                    @Override
+                    public Mono checkPermissions(
+                            Map toolInput, PermissionContextState context) {
+                        return Mono.just(PermissionDecision.allow(""));
+                    }
+                };
+
+        assertEquals("titled_tool", tool.getName());
+        assertEquals("My Custom Title", tool.getTitle());
+    }
+
+    @Test
+    @DisplayName("Builder should handle null title")
+    void testBuilderWithNullTitle() {
+        ToolBase tool =
+                new ToolBase(
+                        ToolBase.builder()
+                                .name("no_title_tool")
+                                .description("desc")
+                                .inputSchema(emptySchema())) {
+                    @Override
+                    public Mono checkPermissions(
+                            Map toolInput, PermissionContextState context) {
+                        return Mono.just(PermissionDecision.allow(""));
+                    }
+                };
+
+        assertNull(tool.getTitle());
+    }
+
+    @Test
+    @DisplayName("getTitle returns builder-set title (final getter)")
+    void testGetTitleReturnsBuilderTitle() {
+        ToolBase tool =
+                new ToolBase(
+                        ToolBase.builder()
+                                .name("fallback_tool")
+                                .title("Custom Fallback")
+                                .description("desc")
+                                .inputSchema(emptySchema())) {
+                    @Override
+                    public Mono checkPermissions(
+                            Map toolInput, PermissionContextState context) {
+                        return Mono.just(PermissionDecision.allow(""));
+                    }
+                };
+
+        assertEquals("Custom Fallback", tool.getTitle());
+    }
 }
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolResultMessageBuilderTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolResultMessageBuilderTest.java
index 82b15b962e..e19ce59042 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolResultMessageBuilderTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolResultMessageBuilderTest.java
@@ -16,10 +16,8 @@
 package io.agentscope.core.tool;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertNull;
 
-import io.agentscope.core.message.ContentBlock;
 import io.agentscope.core.message.Msg;
 import io.agentscope.core.message.MsgRole;
 import io.agentscope.core.message.TextBlock;
@@ -28,87 +26,99 @@
 import java.util.List;
 import java.util.Map;
 import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
 
+/**
+ * Unit tests for {@link ToolResultMessageBuilder}.
+ */
+@Tag("unit")
+@DisplayName("ToolResultMessageBuilder Unit Tests")
 class ToolResultMessageBuilderTest {
 
     @Test
-    @DisplayName("Should build tool result message with single text block")
-    void testBuildWithSingleThinkingBlock() {
-        // Arrange
+    @DisplayName("Should build tool result message with id, name, and title from original call")
+    void testBuildToolResultMsgWithTitle() {
         ToolUseBlock originalCall =
                 ToolUseBlock.builder()
-                        .id("tool_123")
-                        .name("test_tool")
-                        .input(Map.of("param", "value"))
+                        .id("call-1")
+                        .name("search_tool")
+                        .title("Search Tool")
+                        .input(Map.of("query", "hello"))
                         .build();
 
-        ToolResultBlock response =
-                ToolResultBlock.of(TextBlock.builder().text("Success result").build());
-
-        // Act
-        Msg result =
-                ToolResultMessageBuilder.buildToolResultMsg(response, originalCall, "TestAgent");
+        ToolResultBlock result =
+                ToolResultBlock.of(
+                        "result",
+                        "search_tool",
+                        TextBlock.builder().text("found 5 results").build());
 
-        // Assert
-        assertNotNull(result);
-        assertEquals("TestAgent", result.getName());
-        assertEquals(MsgRole.TOOL, result.getRole());
+        Msg msg = ToolResultMessageBuilder.buildToolResultMsg(result, originalCall, "agent-1");
 
-        ContentBlock content = result.getFirstContentBlock();
-        assertTrue(content instanceof ToolResultBlock);
+        assertEquals("agent-1", msg.getName());
+        assertEquals(MsgRole.TOOL, msg.getRole());
 
-        ToolResultBlock toolResult = (ToolResultBlock) content;
-        assertEquals("tool_123", toolResult.getId());
-        assertEquals("test_tool", toolResult.getName());
-
-        List outputs = toolResult.getOutput();
-        assertEquals(1, outputs.size());
-        assertTrue(outputs.get(0) instanceof TextBlock);
-        assertEquals("Success result", ((TextBlock) outputs.get(0)).getText());
+        ToolResultBlock toolResult = (ToolResultBlock) msg.getContent().get(0);
+        assertEquals("call-1", toolResult.getId());
+        assertEquals("search_tool", toolResult.getName());
+        assertEquals("Search Tool", toolResult.getTitle());
     }
 
     @Test
-    @DisplayName("Should handle null content list")
-    void testBuildWithNullContent() {
-        // Arrange
-        ToolUseBlock originalCall =
-                ToolUseBlock.builder().id("tool_000").name("null_tool").input(Map.of()).build();
+    @DisplayName("Should build tool result message with null title")
+    void testBuildToolResultMsgWithNullTitle() {
+        ToolUseBlock originalCall = new ToolUseBlock("call-2", "simple_tool", null, Map.of(), null);
 
-        ToolResultBlock response = ToolResultBlock.of((List) null);
+        ToolResultBlock result =
+                ToolResultBlock.of(null, "simple_tool", TextBlock.builder().text("done").build());
 
-        // Act
-        Msg result =
-                ToolResultMessageBuilder.buildToolResultMsg(response, originalCall, "TestAgent");
+        Msg msg = ToolResultMessageBuilder.buildToolResultMsg(result, originalCall, "agent-2");
 
-        // Assert
-        ToolResultBlock toolResult = (ToolResultBlock) result.getFirstContentBlock();
-        List outputs = toolResult.getOutput();
-        assertTrue(outputs.isEmpty());
+        ToolResultBlock toolResult = (ToolResultBlock) msg.getContent().get(0);
+        assertEquals("call-2", toolResult.getId());
+        assertEquals("simple_tool", toolResult.getName());
+        assertNull(toolResult.getTitle());
     }
 
     @Test
-    @DisplayName("Should preserve original tool call ID and name")
-    void testPreservesOriginalCallInfo() {
-        // Arrange
-        String toolId = "unique_tool_id_12345";
-        String toolName = "important_tool";
+    @DisplayName("Should preserve output blocks from result")
+    void testBuildPreservesOutputBlocks() {
+        ToolUseBlock originalCall =
+                new ToolUseBlock("call-3", "multi_output_tool", Map.of("x", 1), null);
+
+        ToolResultBlock result =
+                ToolResultBlock.of(
+                        null,
+                        "multi_output_tool",
+                        List.of(
+                                TextBlock.builder().text("result 1").build(),
+                                TextBlock.builder().text("result 2").build()));
+
+        Msg msg = ToolResultMessageBuilder.buildToolResultMsg(result, originalCall, "agent-3");
+
+        ToolResultBlock toolResult = (ToolResultBlock) msg.getContent().get(0);
+        assertEquals(2, toolResult.getOutput().size());
+        assertEquals("result 1", ((TextBlock) toolResult.getOutput().get(0)).getText());
+        assertEquals("result 2", ((TextBlock) toolResult.getOutput().get(1)).getText());
+    }
 
+    @Test
+    @DisplayName("Should propagate title from ToolUseBlock with custom title")
+    void testBuildWithCustomTitle() {
         ToolUseBlock originalCall =
                 ToolUseBlock.builder()
-                        .id(toolId)
-                        .name(toolName)
-                        .input(Map.of("key", "value"))
+                        .id("call-4")
+                        .name("query_db")
+                        .title("Query Database")
+                        .input(Map.of("sql", "SELECT *"))
                         .build();
 
-        ToolResultBlock response = ToolResultBlock.of(TextBlock.builder().text("Result").build());
+        ToolResultBlock result =
+                ToolResultBlock.of(null, "query_db", TextBlock.builder().text("rows: 10").build());
 
-        // Act
-        Msg result = ToolResultMessageBuilder.buildToolResultMsg(response, originalCall, "Agent");
+        Msg msg = ToolResultMessageBuilder.buildToolResultMsg(result, originalCall, "agent-4");
 
-        // Assert
-        ToolResultBlock toolResult = (ToolResultBlock) result.getFirstContentBlock();
-        assertEquals(toolId, toolResult.getId());
-        assertEquals(toolName, toolResult.getName());
+        ToolResultBlock toolResult = (ToolResultBlock) msg.getContent().get(0);
+        assertEquals("Query Database", toolResult.getTitle());
     }
 }
diff --git a/agentscope-core/src/test/java/io/agentscope/core/util/ToolUtilsTest.java b/agentscope-core/src/test/java/io/agentscope/core/util/ToolUtilsTest.java
new file mode 100644
index 0000000000..eceec03b14
--- /dev/null
+++ b/agentscope-core/src/test/java/io/agentscope/core/util/ToolUtilsTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.
+ */
+package io.agentscope.core.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.tool.AgentTool;
+import io.agentscope.core.tool.ToolCallParam;
+import io.agentscope.core.tool.Toolkit;
+import java.util.Map;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+/**
+ * Unit tests for {@link ToolUtils}.
+ */
+@Tag("unit")
+@DisplayName("ToolUtils Unit Tests")
+class ToolUtilsTest {
+
+    @Test
+    @DisplayName("Should return null when toolName is null")
+    void testResolveTitleWithNullToolName() {
+        assertNull(ToolUtils.resolveToolTitle(new Toolkit(), null));
+    }
+
+    @Test
+    @DisplayName("Should return null when toolkit is null")
+    void testResolveTitleWithNullToolkit() {
+        assertNull(ToolUtils.resolveToolTitle(null, "some_tool"));
+    }
+
+    @Test
+    @DisplayName("Should return null when tool is not found in toolkit")
+    void testResolveTitleWhenToolNotFound() {
+        Toolkit toolkit = new Toolkit();
+        assertNull(ToolUtils.resolveToolTitle(toolkit, "non_existent_tool"));
+    }
+
+    @Test
+    @DisplayName("Should return tool title when tool exists with custom title")
+    void testResolveTitleWhenToolHasTitle() {
+        Toolkit toolkit = new Toolkit();
+        toolkit.registerAgentTool(stubTool("my_tool", "My Human-Readable Tool"));
+
+        assertEquals("My Human-Readable Tool", ToolUtils.resolveToolTitle(toolkit, "my_tool"));
+    }
+
+    @Test
+    @DisplayName("Should return null title when tool exists but title is null")
+    void testResolveTitleWhenToolHasNullTitle() {
+        Toolkit toolkit = new Toolkit();
+        toolkit.registerAgentTool(stubTool("null_title_tool", null));
+
+        assertNull(ToolUtils.resolveToolTitle(toolkit, "null_title_tool"));
+    }
+
+    @Test
+    @DisplayName("Should return null when both toolkit and toolName are null")
+    void testResolveTitleWithBothNull() {
+        assertNull(ToolUtils.resolveToolTitle(null, null));
+    }
+
+    private static AgentTool stubTool(String name, String title) {
+        return new AgentTool() {
+            @Override
+            public String getName() {
+                return name;
+            }
+
+            @Override
+            public String getTitle() {
+                return title;
+            }
+
+            @Override
+            public String getDescription() {
+                return "";
+            }
+
+            @Override
+            public Mono callAsync(ToolCallParam param) {
+                return Mono.empty();
+            }
+
+            @Override
+            public Map getParameters() {
+                return Map.of();
+            }
+        };
+    }
+}
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/AsyncToolMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/AsyncToolMiddleware.java
index 05c3db91e9..9ce8799478 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/AsyncToolMiddleware.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/AsyncToolMiddleware.java
@@ -15,6 +15,8 @@
  */
 package io.agentscope.harness.agent.middleware;
 
+import static io.agentscope.core.util.ToolUtils.resolveToolTitle;
+
 import io.agentscope.core.agent.Agent;
 import io.agentscope.core.agent.RuntimeContext;
 import io.agentscope.core.event.AgentEvent;
@@ -220,7 +222,12 @@ private void emitPlaceholderAndComplete(
                 state.contextMutable().add(resultMsg);
             }
 
-            sink.next(new ToolResultStartEvent(replyId, toolCall.getId(), toolCall.getName()));
+            sink.next(
+                    new ToolResultStartEvent(
+                            replyId,
+                            toolCall.getId(),
+                            toolCall.getName(),
+                            resolveToolTitle(agent.getToolkit(), toolCall.getName())));
             sink.next(
                     new ToolResultTextDeltaEvent(
                             replyId, toolCall.getId(), toolCall.getName(), placeholderText));
@@ -229,6 +236,7 @@ private void emitPlaceholderAndComplete(
                             replyId,
                             toolCall.getId(),
                             toolCall.getName(),
+                            resolveToolTitle(agent.getToolkit(), toolCall.getName()),
                             ToolResultState.SUCCESS));
         }
         sink.complete();
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/PlanModeMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/PlanModeMiddleware.java
index ac7f5965f2..bb5250775a 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/PlanModeMiddleware.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/PlanModeMiddleware.java
@@ -28,6 +28,7 @@
 import io.agentscope.core.middleware.ActingInput;
 import io.agentscope.core.state.AgentState;
 import io.agentscope.core.tool.ToolResultMessageBuilder;
+import io.agentscope.core.util.ToolUtils;
 import io.agentscope.harness.agent.tool.PlanModeTools;
 import io.agentscope.harness.agent.workspace.plan.PlanModeManager;
 import java.util.ArrayList;
@@ -199,9 +200,13 @@ public Flux onActing(
                         () -> {
                             List events = new ArrayList<>();
                             for (ToolUseBlock call : denied) {
+                                String toolTitle =
+                                        ToolUtils.resolveToolTitle(
+                                                agent.getToolkit(), call.getName());
                                 ToolResultBlock result =
                                         ToolResultBlock.text(DENY_MESSAGE)
-                                                .withIdAndName(call.getId(), call.getName())
+                                                .withIdAndNameAndTitle(
+                                                        call.getId(), call.getName(), toolTitle)
                                                 .withState(ToolResultState.DENIED);
                                 Msg msg =
                                         ToolResultMessageBuilder.buildToolResultMsg(
@@ -209,7 +214,7 @@ public Flux onActing(
                                 state.contextMutable().add(msg);
                                 events.add(
                                         new ToolResultStartEvent(
-                                                replyId, call.getId(), call.getName()));
+                                                replyId, call.getId(), call.getName(), toolTitle));
                                 events.add(
                                         new ToolResultTextDeltaEvent(
                                                 replyId,
@@ -221,6 +226,7 @@ public Flux onActing(
                                                 replyId,
                                                 call.getId(),
                                                 call.getName(),
+                                                toolTitle,
                                                 ToolResultState.DENIED));
                             }
                             return Flux.fromIterable(events);
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/AsyncToolMiddlewareTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/AsyncToolMiddlewareTest.java
index f41cbfc5cf..aee41c9fff 100644
--- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/AsyncToolMiddlewareTest.java
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/AsyncToolMiddlewareTest.java
@@ -65,9 +65,10 @@ void toolCompletesBeforeTimeout_passesThrough() {
 
         Flux downstream =
                 Flux.just(
-                        new ToolResultStartEvent("r1", "t1", "fast_tool"),
+                        new ToolResultStartEvent("r1", "t1", "fast_tool", "Fast Tool"),
                         new ToolResultTextDeltaEvent("r1", "t1", "fast_tool", "result text"),
-                        new ToolResultEndEvent("r1", "t1", "fast_tool", ToolResultState.SUCCESS));
+                        new ToolResultEndEvent(
+                                "r1", "t1", "fast_tool", "Fast Tool", ToolResultState.SUCCESS));
 
         List events =
                 middleware
@@ -95,7 +96,10 @@ void toolExceedsTimeout_emitsPlaceholderAndOffloads() throws Exception {
         ActingInput input = new ActingInput(List.of(tool));
 
         Flux slowDownstream =
-                Flux.just((AgentEvent) new ToolResultStartEvent("r1", "t1", "slow_tool"))
+                Flux.just(
+                                (AgentEvent)
+                                        new ToolResultStartEvent(
+                                                "r1", "t1", "slow_tool", "Slow Tool"))
                         .concatWith(
                                 Mono.delay(Duration.ofSeconds(2))
                                         .thenMany(
@@ -109,6 +113,7 @@ void toolExceedsTimeout_emitsPlaceholderAndOffloads() throws Exception {
                                                                 "r1",
                                                                 "t1",
                                                                 "slow_tool",
+                                                                "Slow Tool",
                                                                 ToolResultState.SUCCESS))));
 
         List events =

From aed6c04d4e4f34584ca9cee228e24fbfb47271fa Mon Sep 17 00:00:00 2001
From: keep simple <3132670669@qq.com>
Date: Fri, 26 Jun 2026 14:22:30 +0800
Subject: [PATCH 2/3] Gets the title of the tool. If not provided, the name
 should be used for display.

---
 .../src/main/java/io/agentscope/core/tool/ToolBase.java         | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java
index 2a6b35d998..3af947863f 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java
@@ -163,7 +163,7 @@ public final String getName() {
 
     @Override
     public final String getTitle() {
-        return title;
+        return title != null ? title : getName();
     }
 
     @Override

From 9ba6e4a1df2eb6d24517200ce73e3a5b438faaf0 Mon Sep 17 00:00:00 2001
From: keep simple <3132670669@qq.com>
Date: Fri, 26 Jun 2026 14:46:31 +0800
Subject: [PATCH 3/3] update unit test

---
 .../agentscope/core/tool/SchemaOnlyToolTest.java | 16 ++++++++--------
 .../io/agentscope/core/tool/ToolBaseTest.java    |  6 +++---
 2 files changed, 11 insertions(+), 11 deletions(-)

diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/SchemaOnlyToolTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/SchemaOnlyToolTest.java
index 45b447b48a..b7c311347f 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/tool/SchemaOnlyToolTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/SchemaOnlyToolTest.java
@@ -219,8 +219,8 @@ void testTitleFromToolSchema() {
     }
 
     @Test
-    @DisplayName("Should handle null title from ToolSchema")
-    void testNullTitleFromToolSchema() {
+    @DisplayName("When schema has no title, getTitle() falls back to getName()")
+    void testNoTitleFromToolSchemaFallsBackToName() {
         ToolSchema schema =
                 ToolSchema.builder()
                         .name("no_title_tool")
@@ -230,7 +230,7 @@ void testNullTitleFromToolSchema() {
 
         SchemaOnlyTool tool = new SchemaOnlyTool(schema);
         assertEquals("no_title_tool", tool.getName());
-        assertNull(tool.getTitle());
+        assertEquals("no_title_tool", tool.getTitle());
     }
 
     @Test
@@ -247,23 +247,23 @@ void testTitleFromFiveArgConstructor() {
     }
 
     @Test
-    @DisplayName("Should keep title null via 4-arg constructor (name, desc, params, strict)")
+    @DisplayName("4-arg constructor without title: getTitle() falls back to getName()")
     void testTitleFromFourArgConstructor() {
         Map params = Map.of("type", "object");
         SchemaOnlyTool tool = new SchemaOnlyTool("simple_tool", "Simple tool", params, null);
 
         assertEquals("simple_tool", tool.getName());
-        assertNull(tool.getTitle());
+        assertEquals("simple_tool", tool.getTitle());
         assertEquals("Simple tool", tool.getDescription());
     }
 
     @Test
-    @DisplayName("Should keep title null via 3-arg constructor (name, desc, params)")
-    void testNullTitleFromThreeArgConstructor() {
+    @DisplayName("3-arg constructor without title: getTitle() falls back to getName()")
+    void testNoTitleFromThreeArgConstructorFallsBackToName() {
         Map params = Map.of("type", "object");
         SchemaOnlyTool tool = new SchemaOnlyTool("simple_tool", "Simple tool", params);
 
         assertEquals("simple_tool", tool.getName());
-        assertNull(tool.getTitle());
+        assertEquals("simple_tool", tool.getTitle());
     }
 }
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolBaseTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolBaseTest.java
index 557babb66d..e5f870f5cb 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolBaseTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolBaseTest.java
@@ -288,8 +288,8 @@ public Mono checkPermissions(
     }
 
     @Test
-    @DisplayName("Builder should handle null title")
-    void testBuilderWithNullTitle() {
+    @DisplayName("When no title is set, getTitle() falls back to getName()")
+    void testBuilderWithoutTitleFallsBackToName() {
         ToolBase tool =
                 new ToolBase(
                         ToolBase.builder()
@@ -303,7 +303,7 @@ public Mono checkPermissions(
                     }
                 };
 
-        assertNull(tool.getTitle());
+        assertEquals("no_title_tool", tool.getTitle());
     }
 
     @Test