Skip to content

Commit d3beaf5

Browse files
committed
feat(extension): support tool schema for completions chat api
Merge tool schema support feature from feat/tool-schema-support. Resolves conflicts with upstream main and includes all accumulated fixes.
2 parents 35ccc77 + 6480631 commit d3beaf5

19 files changed

Lines changed: 1020 additions & 37 deletions

File tree

agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpClientBuilder.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,9 @@ public Mono<McpClientWrapper> buildAsync() {
296296

297297
McpSchema.Implementation clientInfo =
298298
new McpSchema.Implementation(
299-
"agentscope-java", "AgentScope Java Framework", "1.0.10-SNAPSHOT");
299+
"agentscope-java",
300+
"AgentScope Java Framework",
301+
"1.0.10-SNAPSHOT");
300302

301303
McpSchema.ClientCapabilities clientCapabilities =
302304
McpSchema.ClientCapabilities.builder().build();

agentscope-core/src/test/java/io/agentscope/core/VersionTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ void testVersionConstant() {
3030
// Verify version constant is set
3131
Assertions.assertNotNull(Version.VERSION, "VERSION constant should not be null");
3232
Assertions.assertFalse(Version.VERSION.isEmpty(), "VERSION constant should not be empty");
33-
Assertions.assertEquals("1.0.10-SNAPSHOT", Version.VERSION, "VERSION should match current version");
33+
Assertions.assertEquals(
34+
"1.0.10-SNAPSHOT", Version.VERSION, "VERSION should match current version");
3435
}
3536

3637
@Test

agentscope-examples/chat-completions-web/src/main/java/io/agentscope/examples/chatcompletions/ChatCompletionsWebApplication.java

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,89 @@ private static void printStartupInfo() {
9797
""");
9898
System.out.println("\nNote: Accept: text/event-stream header is optional when stream=true");
9999
System.out.println("===================================================");
100+
System.out.println("\nChat completion with Tool Schema (Tool Suspend).\n");
101+
System.out.println(
102+
"""
103+
curl -N -X POST http://localhost:8080/v1/chat/completions \\
104+
-H 'Content-Type: application/json' \\
105+
-d '{
106+
"model": "qwen3-max",
107+
"stream": true,
108+
"messages": [
109+
{ "role": "user", "content": "What is the weather in Hangzhou?" }
110+
],
111+
"tools": [
112+
{
113+
"type": "function",
114+
"function": {
115+
"name": "get_weather",
116+
"description": "Get the current weather for a city",
117+
"parameters": {
118+
"type": "object",
119+
"properties": {
120+
"city": {
121+
"type": "string",
122+
"description": "The city name"
123+
}
124+
},
125+
"required": ["city"]
126+
}
127+
}
128+
}
129+
]
130+
}'
131+
""");
132+
System.out.println("\nNote: Tools are registered as schema-only tools, triggering tool");
133+
System.out.println(" suspension. The response will include tool_calls with");
134+
System.out.println(" finish_reason='tool_calls'. Execute tools externally, then");
135+
System.out.println(" send tool results in the next request:");
136+
System.out.println(
137+
"""
138+
139+
curl -N -X POST http://localhost:8080/v1/chat/completions \\
140+
-H 'Content-Type: application/json' \\
141+
-d '{
142+
"model": "qwen3-max",
143+
"stream": true,
144+
"messages": [
145+
{ "role": "user", "content": "What is the weather in Hangzhou?" },
146+
{
147+
"role": "assistant",
148+
"tool_calls": [{
149+
"id": "call_abc123",
150+
"type": "function",
151+
"function": {
152+
"name": "get_weather",
153+
"arguments": "{\\"city\\":\\"Hangzhou\\"}"
154+
}
155+
}]
156+
},
157+
{
158+
"role": "tool",
159+
"tool_call_id": "call_abc123",
160+
"name": "get_weather",
161+
"content": "Sunny, 25°C"
162+
}
163+
],
164+
"tools": [
165+
{
166+
"type": "function",
167+
"function": {
168+
"name": "get_weather",
169+
"description": "Get the current weather for a city",
170+
"parameters": {
171+
"type": "object",
172+
"properties": {
173+
"city": { "type": "string", "description": "The city name" }
174+
},
175+
"required": ["city"]
176+
}
177+
}
178+
}
179+
]
180+
}'
181+
""");
182+
System.out.println("===================================================");
100183
System.out.println(
101184
"\n⚠️ Important: stream=false with Accept: text/event-stream will return error");
102185
System.out.println(

agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/builder/ChatCompletionsResponseBuilder.java

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import io.agentscope.core.chat.completions.model.ChatMessage;
2424
import io.agentscope.core.chat.completions.model.ToolCall;
2525
import io.agentscope.core.message.ContentBlock;
26+
import io.agentscope.core.message.GenerateReason;
2627
import io.agentscope.core.message.Msg;
2728
import io.agentscope.core.message.MsgRole;
2829
import io.agentscope.core.message.TextBlock;
@@ -99,9 +100,13 @@ public ChatCompletionsResponse buildResponse(
99100
ChatMessage message = convertMsgToChatMessage(reply);
100101
choice.setMessage(message);
101102

102-
// Set finish_reason based on whether there are tool calls
103-
if (message.getToolCalls() != null && !message.getToolCalls().isEmpty()) {
103+
// Set finish_reason based on GenerateReason or tool calls
104+
GenerateReason generateReason = reply != null ? reply.getGenerateReason() : null;
105+
if (generateReason == GenerateReason.TOOL_SUSPENDED
106+
|| (message.getToolCalls() != null && !message.getToolCalls().isEmpty())) {
104107
choice.setFinishReason("tool_calls");
108+
} else if (generateReason == GenerateReason.MAX_ITERATIONS) {
109+
choice.setFinishReason("length");
105110
} else {
106111
choice.setFinishReason("stop");
107112
}
@@ -207,11 +212,29 @@ public String extractTextContent(Msg msg) {
207212
/**
208213
* Convert a ToolUseBlock to a ToolCall, serializing the input Map to JSON string.
209214
*
215+
* <p>Prioritizes the content field (raw JSON string) over the input Map, as some providers
216+
* like DashScope store arguments in the content field.
217+
*
210218
* @param block The ToolUseBlock to convert
211219
* @return The OpenAI-compatible ToolCall
212220
*/
213221
private ToolCall convertToolUseBlockToToolCall(ToolUseBlock block) {
214-
String argumentsJson = serializeMapToJson(block.getInput());
222+
// Prioritize content field (raw JSON string) over input map
223+
// DashScope and some providers store arguments in content field
224+
String argumentsJson;
225+
String content = block.getContent();
226+
Map<String, Object> input = block.getInput();
227+
228+
if (content != null && !content.isEmpty()) {
229+
argumentsJson = content;
230+
} else if (input != null && !input.isEmpty()) {
231+
// Only serialize input if it's not empty
232+
argumentsJson = serializeMapToJson(input);
233+
} else {
234+
// Both content and input are empty - use empty object for non-streaming
235+
argumentsJson = "{}";
236+
}
237+
215238
return new ToolCall(block.getId(), block.getName(), argumentsJson);
216239
}
217240

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Copyright 2024-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* You may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.agentscope.core.chat.completions.converter;
17+
18+
import io.agentscope.core.chat.completions.model.OpenAITool;
19+
import io.agentscope.core.chat.completions.model.OpenAIToolFunction;
20+
import io.agentscope.core.model.ToolSchema;
21+
import java.util.ArrayList;
22+
import java.util.List;
23+
import java.util.Map;
24+
import org.slf4j.Logger;
25+
import org.slf4j.LoggerFactory;
26+
27+
/**
28+
* Converter for converting OpenAI tool format to AgentScope ToolSchema.
29+
*
30+
* <p>This converter handles the transformation from OpenAI's tool format (used in Chat Completions
31+
* API requests) to AgentScope's internal ToolSchema format. Tools converted by this converter are
32+
* intended to be registered as schema-only tools, which will trigger tool suspension when called.
33+
*/
34+
public class OpenAIToolConverter {
35+
36+
private static final Logger log = LoggerFactory.getLogger(OpenAIToolConverter.class);
37+
38+
/**
39+
* Converts a list of OpenAI tools to AgentScope ToolSchemas.
40+
*
41+
* <p>Only tools with type "function" are converted. Other tool types are skipped with a warning.
42+
*
43+
* @param tools The list of OpenAI tools to convert (may be null or empty)
44+
* @return A list of converted ToolSchema objects; returns an empty list if input is null or
45+
* empty
46+
*/
47+
public List<ToolSchema> convertToToolSchemas(List<OpenAITool> tools) {
48+
if (tools == null || tools.isEmpty()) {
49+
return List.of();
50+
}
51+
52+
List<ToolSchema> schemas = new ArrayList<>();
53+
54+
for (OpenAITool tool : tools) {
55+
if (tool == null) {
56+
log.warn("Skipping null tool in conversion");
57+
continue;
58+
}
59+
60+
// Only support function type tools for now
61+
if (!"function".equals(tool.getType())) {
62+
log.warn(
63+
"Skipping tool with unsupported type: {}. Only 'function' type is"
64+
+ " supported",
65+
tool.getType());
66+
continue;
67+
}
68+
69+
OpenAIToolFunction function = tool.getFunction();
70+
if (function == null) {
71+
log.warn("Skipping tool with null function definition");
72+
continue;
73+
}
74+
75+
String name = function.getName();
76+
String description = function.getDescription();
77+
Map<String, Object> parameters = function.getParameters();
78+
79+
// Validate required fields
80+
if (name == null || name.isBlank()) {
81+
log.warn("Skipping tool with null or empty name");
82+
continue;
83+
}
84+
85+
if (description == null || description.isBlank()) {
86+
log.warn("Skipping tool '{}' with null or empty description", name);
87+
// Use empty string as fallback for description
88+
description = "";
89+
}
90+
91+
try {
92+
ToolSchema.Builder schemaBuilder =
93+
ToolSchema.builder().name(name).description(description);
94+
95+
if (parameters != null) {
96+
schemaBuilder.parameters(parameters);
97+
}
98+
99+
if (function.getStrict() != null) {
100+
schemaBuilder.strict(function.getStrict());
101+
}
102+
103+
ToolSchema schema = schemaBuilder.build();
104+
schemas.add(schema);
105+
log.debug("Converted OpenAI tool to ToolSchema: {}", name);
106+
107+
} catch (Exception e) {
108+
log.error("Failed to convert tool '{}' to ToolSchema: {}", name, e.getMessage(), e);
109+
}
110+
}
111+
112+
return schemas;
113+
}
114+
115+
/**
116+
* Converts a single OpenAI tool to a ToolSchema.
117+
*
118+
* @param tool The OpenAI tool to convert
119+
* @return The converted ToolSchema, or null if conversion fails
120+
*/
121+
public ToolSchema convertToToolSchema(OpenAITool tool) {
122+
if (tool == null) {
123+
return null;
124+
}
125+
126+
List<ToolSchema> schemas = convertToToolSchemas(List.of(tool));
127+
return schemas.isEmpty() ? null : schemas.get(0);
128+
}
129+
}

agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ChatCompletionsChunk.java

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ public static ChatCompletionsChunk toolCallChunk(
148148
* standard OpenAI streaming response (since OpenAI doesn't execute tools), AgentScope's
149149
* ReActAgent executes tools internally, so we expose the results in the stream.
150150
*
151+
* <p><b>Important:</b> In OpenAI's streaming API specification, delta.role can only be
152+
* "assistant" or "user". The role "tool" is not supported in streaming responses. Therefore,
153+
* tool results are formatted as assistant content with a prefix indicating the tool name.
154+
*
151155
* <p><b>Example output:</b>
152156
*
153157
* <pre>
@@ -159,31 +163,29 @@ public static ChatCompletionsChunk toolCallChunk(
159163
* "choices": [{
160164
* "index": 0,
161165
* "delta": {
162-
* "role": "tool",
163-
* "tool_call_id": "call_abc",
164-
* "name": "get_weather",
165-
* "content": "The weather is sunny..."
166+
* "role": "assistant",
167+
* "content": "[Tool: get_weather] The weather is sunny..."
166168
* }
167169
* }]
168170
* }
169171
* </pre>
170172
*
171173
* @param id Request ID
172174
* @param model Model name
173-
* @param toolCallId The ID of the tool call this result corresponds to
175+
* @param toolCallId The ID of the tool call this result corresponds to (currently unused in streaming)
174176
* @param toolName The name of the tool that was executed
175177
* @param content The tool execution result content
176-
* @return ChatCompletionsChunk with tool result
178+
* @return ChatCompletionsChunk with tool result formatted as assistant content
177179
*/
178180
public static ChatCompletionsChunk toolResultChunk(
179181
String id, String model, String toolCallId, String toolName, String content) {
180182
ChatCompletionsChunk chunk = new ChatCompletionsChunk(id, model);
181183

182184
ChatMessage delta = new ChatMessage();
183-
delta.setRole("tool");
184-
delta.setToolCallId(toolCallId);
185-
delta.setName(toolName);
186-
delta.setContent(content);
185+
delta.setRole("assistant");
186+
// Format tool result as assistant content for streaming compatibility
187+
// OpenAI streaming API does not support role: "tool" in delta
188+
delta.setContent("[Tool: " + toolName + "] " + content);
187189

188190
ChatChoice choice = new ChatChoice();
189191
choice.setIndex(0);

agentscope-extensions/agentscope-extensions-chat-completions-web/src/main/java/io/agentscope/core/chat/completions/model/ChatCompletionsRequest.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ public class ChatCompletionsRequest {
7171
/** Whether to stream responses via Server-Sent Events (SSE). Optional, defaults to false. */
7272
private Boolean stream;
7373

74+
/**
75+
* A list of tools the model may call. Currently, only functions are supported as a tool.
76+
*
77+
* <p>When tools are provided, they are registered as schema-only tools. When the agent decides
78+
* to call a tool, execution is suspended and the tool call is returned to the client for
79+
* external execution.
80+
*/
81+
private List<OpenAITool> tools;
82+
7483
public String getModel() {
7584
return model;
7685
}
@@ -94,4 +103,12 @@ public Boolean getStream() {
94103
public void setStream(Boolean stream) {
95104
this.stream = stream;
96105
}
106+
107+
public List<OpenAITool> getTools() {
108+
return tools;
109+
}
110+
111+
public void setTools(List<OpenAITool> tools) {
112+
this.tools = tools;
113+
}
97114
}

0 commit comments

Comments
 (0)