schemas = convertToToolSchemas(List.of(tool));
+ return schemas.isEmpty() ? null : schemas.get(0);
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ChatCompletionsChunk.java b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ChatCompletionsChunk.java
index ccdb158ae..6c8e187ad 100644
--- a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ChatCompletionsChunk.java
+++ b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ChatCompletionsChunk.java
@@ -148,6 +148,10 @@ public static ChatCompletionsChunk toolCallChunk(
* standard OpenAI streaming response (since OpenAI doesn't execute tools), AgentScope's
* ReActAgent executes tools internally, so we expose the results in the stream.
*
+ * Important: In OpenAI's streaming API specification, delta.role can only be
+ * "assistant" or "user". The role "tool" is not supported in streaming responses. Therefore,
+ * tool results are formatted as assistant content with a prefix indicating the tool name.
+ *
*
Example output:
*
*
@@ -159,10 +163,8 @@ public static ChatCompletionsChunk toolCallChunk(
* "choices": [{
* "index": 0,
* "delta": {
- * "role": "tool",
- * "tool_call_id": "call_abc",
- * "name": "get_weather",
- * "content": "The weather is sunny..."
+ * "role": "assistant",
+ * "content": "[Tool: get_weather] The weather is sunny..."
* }
* }]
* }
@@ -170,20 +172,20 @@ public static ChatCompletionsChunk toolCallChunk(
*
* @param id Request ID
* @param model Model name
- * @param toolCallId The ID of the tool call this result corresponds to
+ * @param toolCallId The ID of the tool call this result corresponds to (currently unused in streaming)
* @param toolName The name of the tool that was executed
* @param content The tool execution result content
- * @return ChatCompletionsChunk with tool result
+ * @return ChatCompletionsChunk with tool result formatted as assistant content
*/
public static ChatCompletionsChunk toolResultChunk(
String id, String model, String toolCallId, String toolName, String content) {
ChatCompletionsChunk chunk = new ChatCompletionsChunk(id, model);
ChatMessage delta = new ChatMessage();
- delta.setRole("tool");
- delta.setToolCallId(toolCallId);
- delta.setName(toolName);
- delta.setContent(content);
+ delta.setRole("assistant");
+ // Format tool result as assistant content for streaming compatibility
+ // OpenAI streaming API does not support role: "tool" in delta
+ delta.setContent("[Tool: " + toolName + "] " + content);
ChatChoice choice = new ChatChoice();
choice.setIndex(0);
diff --git a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ChatCompletionsRequest.java b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ChatCompletionsRequest.java
index e942ca663..77661dfdb 100644
--- a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ChatCompletionsRequest.java
+++ b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ChatCompletionsRequest.java
@@ -71,6 +71,15 @@ public class ChatCompletionsRequest {
/** Whether to stream responses via Server-Sent Events (SSE). Optional, defaults to false. */
private Boolean stream;
+ /**
+ * A list of tools the model may call. Currently, only functions are supported as a tool.
+ *
+ * When tools are provided, they are registered as schema-only tools. When the agent decides
+ * to call a tool, execution is suspended and the tool call is returned to the client for
+ * external execution.
+ */
+ private List tools;
+
public String getModel() {
return model;
}
@@ -94,4 +103,12 @@ public Boolean getStream() {
public void setStream(Boolean stream) {
this.stream = stream;
}
+
+ public List getTools() {
+ return tools;
+ }
+
+ public void setTools(List tools) {
+ this.tools = tools;
+ }
}
diff --git a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/OpenAITool.java b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/OpenAITool.java
new file mode 100644
index 000000000..1dd16b0cc
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/OpenAITool.java
@@ -0,0 +1,82 @@
+/*
+ * 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.chat.completions.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * OpenAI tool definition for Chat Completions API requests.
+ *
+ * This class represents a tool that can be called by the model, following OpenAI's format.
+ *
+ *
Example:
+ *
{@code
+ * {
+ * "type": "function",
+ * "function": {
+ * "name": "get_weather",
+ * "description": "Get the current weather",
+ * "parameters": {
+ * "type": "object",
+ * "properties": {
+ * "location": {"type": "string"}
+ * },
+ * "required": ["location"]
+ * }
+ * }
+ * }
+ * }
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class OpenAITool {
+
+ /** Tool type, always "function" for now. */
+ @JsonProperty("type")
+ private String type = "function";
+
+ /** The function definition. */
+ @JsonProperty("function")
+ private OpenAIToolFunction function;
+
+ /** Default constructor for deserialization. */
+ public OpenAITool() {}
+
+ /**
+ * Creates a new OpenAITool with the specified function.
+ *
+ * @param function The function definition
+ */
+ public OpenAITool(OpenAIToolFunction function) {
+ this.function = function;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public OpenAIToolFunction getFunction() {
+ return function;
+ }
+
+ public void setFunction(OpenAIToolFunction function) {
+ this.function = function;
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/OpenAIToolFunction.java b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/OpenAIToolFunction.java
new file mode 100644
index 000000000..a0ac995c1
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/OpenAIToolFunction.java
@@ -0,0 +1,95 @@
+/*
+ * 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.chat.completions.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.Map;
+
+/**
+ * OpenAI tool function definition for Chat Completions API requests.
+ *
+ * This class represents the function definition in a tool, following OpenAI's format.
+ *
+ *
Example:
+ *
{@code
+ * {
+ * "name": "get_weather",
+ * "description": "Get the current weather",
+ * "parameters": {
+ * "type": "object",
+ * "properties": {
+ * "location": {"type": "string"}
+ * },
+ * "required": ["location"]
+ * }
+ * }
+ * }
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class OpenAIToolFunction {
+
+ /** The name of the function. */
+ @JsonProperty("name")
+ private String name;
+
+ /** The description of the function. */
+ @JsonProperty("description")
+ private String description;
+
+ /** The JSON Schema for the function parameters. */
+ @JsonProperty("parameters")
+ private Map parameters;
+
+ /** Whether to enable strict mode for schema validation. */
+ @JsonProperty("strict")
+ private Boolean strict;
+
+ /** Default constructor for deserialization. */
+ public OpenAIToolFunction() {}
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public Map getParameters() {
+ return parameters;
+ }
+
+ public void setParameters(Map parameters) {
+ this.parameters = parameters;
+ }
+
+ public Boolean getStrict() {
+ return strict;
+ }
+
+ public void setStrict(Boolean strict) {
+ this.strict = strict;
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ToolCall.java b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ToolCall.java
index 80db55184..47636f4bd 100644
--- a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ToolCall.java
+++ b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ToolCall.java
@@ -15,13 +15,15 @@
*/
package io.agentscope.core.chat.completions.model;
+import com.fasterxml.jackson.annotation.JsonInclude;
+
/**
* Represents a tool call in OpenAI-compatible format.
*
* This DTO is used to serialize tool calls in the conversation context, allowing clients to
* reconstruct the full conversation history including tool invocations.
*
- *
Example JSON:
+ *
Example JSON (non-streaming):
*
*
{@code
* {
@@ -33,9 +35,26 @@
* }
* }
* }
+ *
+ * Example JSON (streaming with index):
+ *
+ *
{@code
+ * {
+ * "index": 0,
+ * "id": "call_abc123",
+ * "type": "function",
+ * "function": {
+ * "name": "get_weather",
+ * "arguments": "{\"city\":\"Hangzhou\"}"
+ * }
+ * }
+ * }
*/
+@JsonInclude(JsonInclude.Include.NON_NULL)
public class ToolCall {
+ private Integer index;
+
private String id;
private String type = "function";
@@ -58,6 +77,29 @@ public ToolCall(String id, String name, String arguments) {
this.function = new FunctionCall(name, arguments);
}
+ /**
+ * Creates a new tool call with index (for streaming).
+ *
+ * @param index Index in the tool_calls array
+ * @param id Unique identifier for this tool call
+ * @param name Function name
+ * @param arguments JSON string of function arguments
+ */
+ public ToolCall(Integer index, String id, String name, String arguments) {
+ this.index = index;
+ this.id = id;
+ this.type = "function";
+ this.function = new FunctionCall(name, arguments);
+ }
+
+ public Integer getIndex() {
+ return index;
+ }
+
+ public void setIndex(Integer index) {
+ this.index = index;
+ }
+
public String getId() {
return id;
}
diff --git a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/streaming/ChatCompletionsStreamingAdapter.java b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/streaming/ChatCompletionsStreamingAdapter.java
index 54c584b01..800de52ee 100644
--- a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/streaming/ChatCompletionsStreamingAdapter.java
+++ b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/streaming/ChatCompletionsStreamingAdapter.java
@@ -196,13 +196,33 @@ private Flux convertEventToChunksInternal(
// Extract tool calls (only for REASONING events from assistant)
if (event.getType() == EventType.REASONING) {
List toolCalls = new ArrayList<>();
+ int toolCallIndex = 0;
for (ContentBlock block : contentBlocks) {
if (block instanceof ToolUseBlock) {
ToolUseBlock toolUseBlock = (ToolUseBlock) block;
- String argumentsJson = serializeMapToJson(toolUseBlock.getInput());
+ // Prioritize content field (raw JSON string) over input map
+ // DashScope and some providers store arguments in content field
+ String argumentsJson;
+ String content = toolUseBlock.getContent();
+ Map input = toolUseBlock.getInput();
+
+ if (content != null && !content.isEmpty()) {
+ argumentsJson = content;
+ } else if (input != null && !input.isEmpty()) {
+ // Only serialize input if it's not empty
+ argumentsJson = serializeMapToJson(input);
+ } else {
+ // Both content and input are empty - use empty string for streaming
+ // This allows clients to accumulate subsequent chunks correctly
+ argumentsJson = "";
+ }
toolCalls.add(
new ToolCall(
- toolUseBlock.getId(), toolUseBlock.getName(), argumentsJson));
+ toolCallIndex,
+ toolUseBlock.getId(),
+ toolUseBlock.getName(),
+ argumentsJson));
+ toolCallIndex++;
}
}
diff --git a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/converter/OpenAIToolConverterTest.java b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/converter/OpenAIToolConverterTest.java
new file mode 100644
index 000000000..7756b120d
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/converter/OpenAIToolConverterTest.java
@@ -0,0 +1,296 @@
+/*
+ * 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.chat.completions.converter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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 io.agentscope.core.chat.completions.model.OpenAITool;
+import io.agentscope.core.chat.completions.model.OpenAIToolFunction;
+import io.agentscope.core.model.ToolSchema;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link OpenAIToolConverter}.
+ */
+@DisplayName("OpenAIToolConverter Tests")
+class OpenAIToolConverterTest {
+
+ private OpenAIToolConverter converter;
+
+ @BeforeEach
+ void setUp() {
+ converter = new OpenAIToolConverter();
+ }
+
+ @Nested
+ @DisplayName("Convert To ToolSchemas Tests")
+ class ConvertToToolSchemasTests {
+
+ @Test
+ @DisplayName("Should convert valid function tool to ToolSchema")
+ void shouldConvertValidFunctionToolToToolSchema() {
+ OpenAIToolFunction function = new OpenAIToolFunction();
+ function.setName("get_weather");
+ function.setDescription("Get weather for a location");
+ function.setParameters(
+ Map.of(
+ "type",
+ "object",
+ "properties",
+ Map.of("location", Map.of("type", "string"))));
+
+ OpenAITool tool = new OpenAITool(function);
+
+ List schemas = converter.convertToToolSchemas(List.of(tool));
+
+ assertEquals(1, schemas.size());
+ ToolSchema schema = schemas.get(0);
+ assertEquals("get_weather", schema.getName());
+ assertEquals("Get weather for a location", schema.getDescription());
+ assertNotNull(schema.getParameters());
+ }
+
+ @Test
+ @DisplayName("Should return empty list for null input")
+ void shouldReturnEmptyListForNullInput() {
+ List schemas = converter.convertToToolSchemas(null);
+
+ assertTrue(schemas.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should return empty list for empty input")
+ void shouldReturnEmptyListForEmptyInput() {
+ List schemas = converter.convertToToolSchemas(List.of());
+
+ assertTrue(schemas.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should skip null tools in list")
+ void shouldSkipNullToolsInList() {
+ OpenAIToolFunction function = new OpenAIToolFunction();
+ function.setName("valid_tool");
+ function.setDescription("Valid");
+ OpenAITool validTool = new OpenAITool(function);
+
+ List tools = new ArrayList<>();
+ tools.add(validTool);
+ tools.add(null);
+
+ List schemas = converter.convertToToolSchemas(tools);
+
+ assertEquals(1, schemas.size());
+ assertEquals("valid_tool", schemas.get(0).getName());
+ }
+
+ @Test
+ @DisplayName("Should skip non-function type tools")
+ void shouldSkipNonFunctionTypeTools() {
+ OpenAITool tool = new OpenAITool();
+ tool.setType("code_interpreter"); // Not supported
+
+ List schemas = converter.convertToToolSchemas(List.of(tool));
+
+ assertTrue(schemas.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should skip tools with null function")
+ void shouldSkipToolsWithNullFunction() {
+ OpenAITool tool = new OpenAITool();
+ tool.setType("function");
+ tool.setFunction(null);
+
+ List schemas = converter.convertToToolSchemas(List.of(tool));
+
+ assertTrue(schemas.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should skip tools with null or empty name")
+ void shouldSkipToolsWithNullOrEmptyName() {
+ OpenAIToolFunction function1 = new OpenAIToolFunction();
+ function1.setName(null);
+ function1.setDescription("Test");
+ OpenAITool tool1 = new OpenAITool(function1);
+
+ OpenAIToolFunction function2 = new OpenAIToolFunction();
+ function2.setName("");
+ function2.setDescription("Test");
+ OpenAITool tool2 = new OpenAITool(function2);
+
+ OpenAIToolFunction function3 = new OpenAIToolFunction();
+ function3.setName(" ");
+ function3.setDescription("Test");
+ OpenAITool tool3 = new OpenAITool(function3);
+
+ List schemas = converter.convertToToolSchemas(List.of(tool1, tool2, tool3));
+
+ assertTrue(schemas.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should use empty string for null or empty description")
+ void shouldUseEmptyStringForNullOrEmptyDescription() {
+ OpenAIToolFunction function1 = new OpenAIToolFunction();
+ function1.setName("tool1");
+ function1.setDescription(null);
+ OpenAITool tool1 = new OpenAITool(function1);
+
+ OpenAIToolFunction function2 = new OpenAIToolFunction();
+ function2.setName("tool2");
+ function2.setDescription("");
+ OpenAITool tool2 = new OpenAITool(function2);
+
+ List schemas = converter.convertToToolSchemas(List.of(tool1, tool2));
+
+ assertEquals(2, schemas.size());
+ assertEquals("", schemas.get(0).getDescription());
+ assertEquals("", schemas.get(1).getDescription());
+ }
+
+ @Test
+ @DisplayName("Should handle tools with null parameters")
+ void shouldHandleToolsWithNullParameters() {
+ OpenAIToolFunction function = new OpenAIToolFunction();
+ function.setName("no_params");
+ function.setDescription("No parameters");
+ function.setParameters(null);
+
+ OpenAITool tool = new OpenAITool(function);
+
+ List schemas = converter.convertToToolSchemas(List.of(tool));
+
+ assertEquals(1, schemas.size());
+ // ToolSchema converts null parameters to empty map
+ assertNotNull(schemas.get(0).getParameters());
+ assertTrue(schemas.get(0).getParameters().isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should preserve strict parameter")
+ void shouldPreserveStrictParameter() {
+ OpenAIToolFunction function = new OpenAIToolFunction();
+ function.setName("strict_tool");
+ function.setDescription("Strict tool");
+ function.setStrict(true);
+
+ OpenAITool tool = new OpenAITool(function);
+
+ List schemas = converter.convertToToolSchemas(List.of(tool));
+
+ assertEquals(1, schemas.size());
+ assertTrue(schemas.get(0).getStrict());
+ }
+
+ @Test
+ @DisplayName("Should convert multiple tools")
+ void shouldConvertMultipleTools() {
+ OpenAIToolFunction function1 = new OpenAIToolFunction();
+ function1.setName("tool1");
+ function1.setDescription("Tool 1");
+ OpenAITool tool1 = new OpenAITool(function1);
+
+ OpenAIToolFunction function2 = new OpenAIToolFunction();
+ function2.setName("tool2");
+ function2.setDescription("Tool 2");
+ OpenAITool tool2 = new OpenAITool(function2);
+
+ List schemas = converter.convertToToolSchemas(List.of(tool1, tool2));
+
+ assertEquals(2, schemas.size());
+ assertEquals("tool1", schemas.get(0).getName());
+ assertEquals("tool2", schemas.get(1).getName());
+ }
+
+ @Test
+ @DisplayName("Should handle complex parameters")
+ void shouldHandleComplexParameters() {
+ Map properties = new HashMap<>();
+ properties.put("location", Map.of("type", "string", "description", "City name"));
+ properties.put(
+ "unit", Map.of("type", "string", "enum", List.of("celsius", "fahrenheit")));
+
+ Map parameters = new HashMap<>();
+ parameters.put("type", "object");
+ parameters.put("properties", properties);
+ parameters.put("required", List.of("location"));
+
+ OpenAIToolFunction function = new OpenAIToolFunction();
+ function.setName("get_weather");
+ function.setDescription("Get weather");
+ function.setParameters(parameters);
+
+ OpenAITool tool = new OpenAITool(function);
+
+ List schemas = converter.convertToToolSchemas(List.of(tool));
+
+ assertEquals(1, schemas.size());
+ assertNotNull(schemas.get(0).getParameters());
+ assertEquals(parameters, schemas.get(0).getParameters());
+ }
+ }
+
+ @Nested
+ @DisplayName("Convert To ToolSchema Tests")
+ class ConvertToToolSchemaTests {
+
+ @Test
+ @DisplayName("Should convert single tool to ToolSchema")
+ void shouldConvertSingleToolToToolSchema() {
+ OpenAIToolFunction function = new OpenAIToolFunction();
+ function.setName("single_tool");
+ function.setDescription("Single tool");
+
+ OpenAITool tool = new OpenAITool(function);
+
+ ToolSchema schema = converter.convertToToolSchema(tool);
+
+ assertNotNull(schema);
+ assertEquals("single_tool", schema.getName());
+ }
+
+ @Test
+ @DisplayName("Should return null for null input")
+ void shouldReturnNullForNullInput() {
+ ToolSchema schema = converter.convertToToolSchema(null);
+
+ assertNull(schema);
+ }
+
+ @Test
+ @DisplayName("Should return null for invalid tool")
+ void shouldReturnNullForInvalidTool() {
+ OpenAITool tool = new OpenAITool();
+ tool.setType("code_interpreter"); // Invalid type
+
+ ToolSchema schema = converter.convertToToolSchema(tool);
+
+ assertNull(schema);
+ }
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/model/ChatCompletionsChunkTest.java b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/model/ChatCompletionsChunkTest.java
index 7b42fe065..4171245d7 100644
--- a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/model/ChatCompletionsChunkTest.java
+++ b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/model/ChatCompletionsChunkTest.java
@@ -137,6 +137,52 @@ void shouldHandleMultipleToolCalls() {
}
}
+ @Nested
+ @DisplayName("Tool Result Chunk Tests")
+ class ToolResultChunkTests {
+
+ @Test
+ @DisplayName("Should create tool result chunk correctly")
+ void shouldCreateToolResultChunkCorrectly() {
+ ChatCompletionsChunk chunk =
+ ChatCompletionsChunk.toolResultChunk(
+ "req-123", "gpt-4", "call-1", "get_weather", "Sunny, 25°C");
+
+ assertEquals("req-123", chunk.getId());
+ assertEquals("gpt-4", chunk.getModel());
+ assertNotNull(chunk.getChoices());
+ assertEquals(1, chunk.getChoices().size());
+
+ ChatChoice choice = chunk.getChoices().get(0);
+ assertEquals(0, choice.getIndex());
+ assertNotNull(choice.getDelta());
+ assertEquals("assistant", choice.getDelta().getRole());
+ assertEquals("[Tool: get_weather] Sunny, 25°C", choice.getDelta().getContent());
+ }
+
+ @Test
+ @DisplayName("Should format tool result with tool name prefix")
+ void shouldFormatToolResultWithToolNamePrefix() {
+ ChatCompletionsChunk chunk =
+ ChatCompletionsChunk.toolResultChunk(
+ "req-123", "gpt-4", "call-1", "search", "Result content");
+
+ String content = chunk.getChoices().get(0).getDelta().getContent();
+ assertTrue(content.startsWith("[Tool: search] "));
+ assertTrue(content.contains("Result content"));
+ }
+
+ @Test
+ @DisplayName("Should handle empty tool result content")
+ void shouldHandleEmptyToolResultContent() {
+ ChatCompletionsChunk chunk =
+ ChatCompletionsChunk.toolResultChunk(
+ "req-123", "gpt-4", "call-1", "empty_tool", "");
+
+ assertEquals("[Tool: empty_tool] ", chunk.getChoices().get(0).getDelta().getContent());
+ }
+ }
+
@Nested
@DisplayName("Finish Chunk Tests")
class FinishChunkTests {
diff --git a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/model/ChatCompletionsRequestTest.java b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/model/ChatCompletionsRequestTest.java
index 15c8ab017..e614db10f 100644
--- a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/model/ChatCompletionsRequestTest.java
+++ b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/model/ChatCompletionsRequestTest.java
@@ -173,6 +173,47 @@ void shouldHandleNullStream() {
}
}
+ @Nested
+ @DisplayName("Tools Tests")
+ class ToolsTests {
+
+ @Test
+ @DisplayName("Should set and get tools")
+ void shouldSetAndGetTools() {
+ ChatCompletionsRequest request = new ChatCompletionsRequest();
+ io.agentscope.core.chat.completions.model.OpenAIToolFunction function =
+ new io.agentscope.core.chat.completions.model.OpenAIToolFunction();
+ function.setName("get_weather");
+ function.setDescription("Get weather");
+ io.agentscope.core.chat.completions.model.OpenAITool tool =
+ new io.agentscope.core.chat.completions.model.OpenAITool(function);
+
+ request.setTools(List.of(tool));
+
+ assertNotNull(request.getTools());
+ assertEquals(1, request.getTools().size());
+ assertEquals("get_weather", request.getTools().get(0).getFunction().getName());
+ }
+
+ @Test
+ @DisplayName("Should handle null tools")
+ void shouldHandleNullTools() {
+ ChatCompletionsRequest request = new ChatCompletionsRequest();
+
+ assertNull(request.getTools());
+ }
+
+ @Test
+ @DisplayName("Should handle empty tools list")
+ void shouldHandleEmptyToolsList() {
+ ChatCompletionsRequest request = new ChatCompletionsRequest();
+ request.setTools(new ArrayList<>());
+
+ assertNotNull(request.getTools());
+ assertTrue(request.getTools().isEmpty());
+ }
+ }
+
@Nested
@DisplayName("Complete Request Tests")
class CompleteRequestTests {
@@ -192,5 +233,28 @@ void shouldBuildCompleteRequest() {
assertEquals(2, request.getMessages().size());
assertFalse(request.getStream());
}
+
+ @Test
+ @DisplayName("Should build complete request with tools")
+ void shouldBuildCompleteRequestWithTools() {
+ ChatCompletionsRequest request = new ChatCompletionsRequest();
+ request.setModel("gpt-4");
+ request.setMessages(List.of(new ChatMessage("user", "Hello")));
+ request.setStream(false);
+
+ io.agentscope.core.chat.completions.model.OpenAIToolFunction function =
+ new io.agentscope.core.chat.completions.model.OpenAIToolFunction();
+ function.setName("get_weather");
+ function.setDescription("Get weather");
+ io.agentscope.core.chat.completions.model.OpenAITool tool =
+ new io.agentscope.core.chat.completions.model.OpenAITool(function);
+ request.setTools(List.of(tool));
+
+ assertEquals("gpt-4", request.getModel());
+ assertEquals(1, request.getMessages().size());
+ assertFalse(request.getStream());
+ assertNotNull(request.getTools());
+ assertEquals(1, request.getTools().size());
+ }
}
}
diff --git a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/model/ToolCallTest.java b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/model/ToolCallTest.java
index b5c732fe3..bc46416fd 100644
--- a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/model/ToolCallTest.java
+++ b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/model/ToolCallTest.java
@@ -52,10 +52,37 @@ void shouldCreateToolCallWithAllParameters() {
assertEquals("call-123", toolCall.getId());
assertEquals("function", toolCall.getType());
+ assertNull(toolCall.getIndex());
assertNotNull(toolCall.getFunction());
assertEquals("get_weather", toolCall.getFunction().getName());
assertEquals("{\"city\":\"Hangzhou\"}", toolCall.getFunction().getArguments());
}
+
+ @Test
+ @DisplayName("Should create tool call with index for streaming")
+ void shouldCreateToolCallWithIndexForStreaming() {
+ ToolCall toolCall =
+ new ToolCall(0, "call-123", "get_weather", "{\"city\":\"Hangzhou\"}");
+
+ assertEquals(0, toolCall.getIndex());
+ assertEquals("call-123", toolCall.getId());
+ assertEquals("function", toolCall.getType());
+ assertNotNull(toolCall.getFunction());
+ assertEquals("get_weather", toolCall.getFunction().getName());
+ assertEquals("{\"city\":\"Hangzhou\"}", toolCall.getFunction().getArguments());
+ }
+
+ @Test
+ @DisplayName("Should create tool call with multiple indices")
+ void shouldCreateToolCallWithMultipleIndices() {
+ ToolCall toolCall1 = new ToolCall(0, "call-1", "tool1", "{}");
+ ToolCall toolCall2 = new ToolCall(1, "call-2", "tool2", "{}");
+ ToolCall toolCall3 = new ToolCall(2, "call-3", "tool3", "{}");
+
+ assertEquals(0, toolCall1.getIndex());
+ assertEquals(1, toolCall2.getIndex());
+ assertEquals(2, toolCall3.getIndex());
+ }
}
@Nested
@@ -104,10 +131,12 @@ void shouldSetAndGetAllProperties() {
toolCall.setId("custom-id");
toolCall.setType("custom-type");
+ toolCall.setIndex(5);
toolCall.setFunction(new ToolCall.FunctionCall("func", "{}"));
assertEquals("custom-id", toolCall.getId());
assertEquals("custom-type", toolCall.getType());
+ assertEquals(5, toolCall.getIndex());
assertNotNull(toolCall.getFunction());
assertEquals("func", toolCall.getFunction().getName());
}
diff --git a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/streaming/ChatCompletionsStreamingAdapterTest.java b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/streaming/ChatCompletionsStreamingAdapterTest.java
index 4f4e79214..3cac3976c 100644
--- a/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/streaming/ChatCompletionsStreamingAdapterTest.java
+++ b/agentscope-extensions/agentscope-extensions-chat-completions-web/src/test/java/io/agentscope/core/chat/completions/streaming/ChatCompletionsStreamingAdapterTest.java
@@ -295,16 +295,13 @@ void shouldExtractToolResultFromToolResultEvents() {
chunk -> {
assertNotNull(chunk.getChoices());
assertEquals(1, chunk.getChoices().size());
+ // Tool results are now formatted as assistant content for streaming
+ // compatibility
assertEquals(
- "tool", chunk.getChoices().get(0).getDelta().getRole());
+ "assistant",
+ chunk.getChoices().get(0).getDelta().getRole());
assertEquals(
- "call-123",
- chunk.getChoices().get(0).getDelta().getToolCallId());
- assertEquals(
- "get_weather",
- chunk.getChoices().get(0).getDelta().getName());
- assertEquals(
- "Sunny, 25°C",
+ "[Tool: get_weather] Sunny, 25°C",
chunk.getChoices().get(0).getDelta().getContent());
})
.verifyComplete();
@@ -330,20 +327,23 @@ void shouldHandleMultipleToolResultsInSingleEvent() {
StepVerifier.create(result)
.assertNext(
chunk -> {
+ // Tool results are now formatted as assistant content with tool
+ // name
+ // prefix
assertEquals(
- "call-1",
- chunk.getChoices().get(0).getDelta().getToolCallId());
+ "assistant",
+ chunk.getChoices().get(0).getDelta().getRole());
assertEquals(
- "Result A",
+ "[Tool: tool_a] Result A",
chunk.getChoices().get(0).getDelta().getContent());
})
.assertNext(
chunk -> {
assertEquals(
- "call-2",
- chunk.getChoices().get(0).getDelta().getToolCallId());
+ "assistant",
+ chunk.getChoices().get(0).getDelta().getRole());
assertEquals(
- "Result B",
+ "[Tool: tool_b] Result B",
chunk.getChoices().get(0).getDelta().getContent());
})
.verifyComplete();
@@ -493,7 +493,9 @@ void shouldHandleEmptyToolInput() {
.get(0)
.getFunction()
.getArguments();
- assertEquals("{}", args);
+ // For streaming, empty arguments should be empty string, not "{}"
+ // This allows clients to accumulate subsequent chunks correctly
+ assertEquals("", args);
})
.verifyComplete();
}
diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-chat-completions-web-starter/src/main/java/io/agentscope/spring/boot/chat/config/ChatCompletionsWebAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-chat-completions-web-starter/src/main/java/io/agentscope/spring/boot/chat/config/ChatCompletionsWebAutoConfiguration.java
index d21d9f040..7c26bba66 100644
--- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-chat-completions-web-starter/src/main/java/io/agentscope/spring/boot/chat/config/ChatCompletionsWebAutoConfiguration.java
+++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-chat-completions-web-starter/src/main/java/io/agentscope/spring/boot/chat/config/ChatCompletionsWebAutoConfiguration.java
@@ -18,6 +18,7 @@
import io.agentscope.core.ReActAgent;
import io.agentscope.core.chat.completions.builder.ChatCompletionsResponseBuilder;
import io.agentscope.core.chat.completions.converter.ChatMessageConverter;
+import io.agentscope.core.chat.completions.converter.OpenAIToolConverter;
import io.agentscope.core.chat.completions.streaming.ChatCompletionsStreamingAdapter;
import io.agentscope.spring.boot.chat.service.ChatCompletionsStreamingService;
import io.agentscope.spring.boot.chat.web.ChatCompletionsController;
@@ -71,6 +72,17 @@ public ChatMessageConverter chatMessageConverter() {
return new ChatMessageConverter();
}
+ /**
+ * Create the OpenAI tool converter bean.
+ *
+ * @return A new {@link OpenAIToolConverter} instance for converting OpenAI tools to ToolSchema
+ */
+ @Bean
+ @ConditionalOnMissingBean
+ public OpenAIToolConverter openAIToolConverter() {
+ return new OpenAIToolConverter();
+ }
+
/**
* Create the response builder bean.
*
@@ -121,6 +133,7 @@ public ChatCompletionsStreamingService chatCompletionsStreamingService(
* @param messageConverter Converter for HTTP DTOs to framework messages
* @param responseBuilder Builder for response objects
* @param streamingService Service for streaming responses
+ * @param toolConverter Converter for OpenAI tools to ToolSchema
* @return The configured ChatCompletionsController bean
*/
@Bean
@@ -129,8 +142,9 @@ public ChatCompletionsController chatCompletionsController(
ObjectProvider agentProvider,
ChatMessageConverter messageConverter,
ChatCompletionsResponseBuilder responseBuilder,
- ChatCompletionsStreamingService streamingService) {
+ ChatCompletionsStreamingService streamingService,
+ OpenAIToolConverter toolConverter) {
return new ChatCompletionsController(
- agentProvider, messageConverter, responseBuilder, streamingService);
+ agentProvider, messageConverter, responseBuilder, streamingService, toolConverter);
}
}
diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-chat-completions-web-starter/src/main/java/io/agentscope/spring/boot/chat/web/ChatCompletionsController.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-chat-completions-web-starter/src/main/java/io/agentscope/spring/boot/chat/web/ChatCompletionsController.java
index 711a8ddaa..d3bc85027 100644
--- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-chat-completions-web-starter/src/main/java/io/agentscope/spring/boot/chat/web/ChatCompletionsController.java
+++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-chat-completions-web-starter/src/main/java/io/agentscope/spring/boot/chat/web/ChatCompletionsController.java
@@ -18,6 +18,7 @@
import io.agentscope.core.ReActAgent;
import io.agentscope.core.chat.completions.builder.ChatCompletionsResponseBuilder;
import io.agentscope.core.chat.completions.converter.ChatMessageConverter;
+import io.agentscope.core.chat.completions.converter.OpenAIToolConverter;
import io.agentscope.core.chat.completions.model.ChatCompletionsRequest;
import io.agentscope.core.chat.completions.model.ChatCompletionsResponse;
import io.agentscope.core.message.Msg;
@@ -83,6 +84,7 @@ public class ChatCompletionsController {
private final ChatMessageConverter messageConverter;
private final ChatCompletionsResponseBuilder responseBuilder;
private final ChatCompletionsStreamingService streamingService;
+ private final OpenAIToolConverter toolConverter;
/**
* Constructs a new ChatCompletionsController.
@@ -91,16 +93,19 @@ public class ChatCompletionsController {
* @param messageConverter Converter for HTTP DTOs to framework messages
* @param responseBuilder Builder for response objects
* @param streamingService Service for streaming responses
+ * @param toolConverter Converter for OpenAI tools to ToolSchema
*/
public ChatCompletionsController(
ObjectProvider agentProvider,
ChatMessageConverter messageConverter,
ChatCompletionsResponseBuilder responseBuilder,
- ChatCompletionsStreamingService streamingService) {
+ ChatCompletionsStreamingService streamingService,
+ OpenAIToolConverter toolConverter) {
this.agentProvider = agentProvider;
this.messageConverter = messageConverter;
this.responseBuilder = responseBuilder;
this.streamingService = streamingService;
+ this.toolConverter = toolConverter;
}
/**
@@ -147,6 +152,18 @@ public Object createCompletion(@Valid @RequestBody ChatCompletionsRequest reques
"Failed to create ReActAgent: agentProvider returned null"));
}
+ // Register schema-only tools from request if provided
+ if (request.getTools() != null && !request.getTools().isEmpty()) {
+ var toolSchemas = toolConverter.convertToToolSchemas(request.getTools());
+ if (!toolSchemas.isEmpty()) {
+ agent.getToolkit().registerSchemas(toolSchemas);
+ log.debug(
+ "Registered {} schema-only tools from request: requestId={}",
+ toolSchemas.size(),
+ requestId);
+ }
+ }
+
// Convert all messages from the request
List messages = messageConverter.convertMessages(request.getMessages());
if (messages.isEmpty()) {
@@ -230,6 +247,18 @@ public Flux> createCompletionStream(
"Failed to create ReActAgent: agentProvider returned null"));
}
+ // Register schema-only tools from request if provided
+ if (request.getTools() != null && !request.getTools().isEmpty()) {
+ var toolSchemas = toolConverter.convertToToolSchemas(request.getTools());
+ if (!toolSchemas.isEmpty()) {
+ agent.getToolkit().registerSchemas(toolSchemas);
+ log.debug(
+ "Registered {} schema-only tools from request: requestId={}",
+ toolSchemas.size(),
+ requestId);
+ }
+ }
+
// Convert all messages from the request
List messages = messageConverter.convertMessages(request.getMessages());
if (messages.isEmpty()) {
diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-chat-completions-web-starter/src/test/java/io/agentscope/spring/boot/chat/web/ChatCompletionsControllerTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-chat-completions-web-starter/src/test/java/io/agentscope/spring/boot/chat/web/ChatCompletionsControllerTest.java
index 93b20a786..d60b2d8cc 100644
--- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-chat-completions-web-starter/src/test/java/io/agentscope/spring/boot/chat/web/ChatCompletionsControllerTest.java
+++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-chat-completions-web-starter/src/test/java/io/agentscope/spring/boot/chat/web/ChatCompletionsControllerTest.java
@@ -27,6 +27,7 @@
import io.agentscope.core.ReActAgent;
import io.agentscope.core.chat.completions.builder.ChatCompletionsResponseBuilder;
import io.agentscope.core.chat.completions.converter.ChatMessageConverter;
+import io.agentscope.core.chat.completions.converter.OpenAIToolConverter;
import io.agentscope.core.chat.completions.model.ChatCompletionsRequest;
import io.agentscope.core.chat.completions.model.ChatCompletionsResponse;
import io.agentscope.core.chat.completions.model.ChatMessage;
@@ -61,6 +62,7 @@ class ChatCompletionsControllerTest {
private ChatMessageConverter messageConverter;
private ChatCompletionsResponseBuilder responseBuilder;
private ChatCompletionsStreamingService streamingService;
+ private OpenAIToolConverter toolConverter;
private ReActAgent mockAgent;
@SuppressWarnings("unchecked")
@@ -70,6 +72,7 @@ void setUp() {
messageConverter = mock(ChatMessageConverter.class);
responseBuilder = mock(ChatCompletionsResponseBuilder.class);
streamingService = mock(ChatCompletionsStreamingService.class);
+ toolConverter = mock(OpenAIToolConverter.class);
mockAgent = mock(ReActAgent.class);
// Default: agentProvider returns mockAgent
@@ -77,7 +80,11 @@ void setUp() {
controller =
new ChatCompletionsController(
- agentProvider, messageConverter, responseBuilder, streamingService);
+ agentProvider,
+ messageConverter,
+ responseBuilder,
+ streamingService,
+ toolConverter);
}
@Nested