diff --git a/agentscope-distribution/agentscope-all/pom.xml b/agentscope-distribution/agentscope-all/pom.xml index 921cc52fb..8458d14e2 100644 --- a/agentscope-distribution/agentscope-all/pom.xml +++ b/agentscope-distribution/agentscope-all/pom.xml @@ -80,6 +80,13 @@ true + + io.agentscope + agentscope-extensions-responses-web + compile + true + + io.agentscope agentscope-extensions-agent-protocol diff --git a/agentscope-distribution/agentscope-bom/pom.xml b/agentscope-distribution/agentscope-bom/pom.xml index 8daaa4f4d..a828a6886 100644 --- a/agentscope-distribution/agentscope-bom/pom.xml +++ b/agentscope-distribution/agentscope-bom/pom.xml @@ -127,6 +127,13 @@ ${project.version} + + + io.agentscope + agentscope-extensions-responses-web + ${project.version} + + io.agentscope @@ -349,6 +356,13 @@ ${project.version} + + + io.agentscope + agentscope-responses-web-starter + ${project.version} + + io.agentscope diff --git a/agentscope-examples/pom.xml b/agentscope-examples/pom.xml index 69fafee5e..a383808fd 100644 --- a/agentscope-examples/pom.xml +++ b/agentscope-examples/pom.xml @@ -39,6 +39,7 @@ documentation/werewolf-hitl documentation/model-request-compression documentation/chat-completions-web + responses-web documentation/hitl-chat documentation/a2a documentation/chat-tts @@ -67,4 +68,4 @@ 4.10.13 - \ No newline at end of file + diff --git a/agentscope-examples/responses-web/pom.xml b/agentscope-examples/responses-web/pom.xml new file mode 100644 index 000000000..32365a934 --- /dev/null +++ b/agentscope-examples/responses-web/pom.xml @@ -0,0 +1,90 @@ + + + + 4.0.0 + + + io.agentscope + agentscope-examples + ${revision} + ../pom.xml + + + io.agentscope.examples + responses-web + jar + + AgentScope Examples - Responses Web + Example Spring Boot app using the Responses Web Starter + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + io.agentscope + agentscope-core + + + org.springframework.boot + spring-boot-starter-web + + + io.agentscope + agentscope-spring-boot-starter + + + io.agentscope + agentscope + + + + + io.agentscope + agentscope-responses-web-starter + ${revision} + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + + repackage + + + + + + + diff --git a/agentscope-examples/responses-web/src/main/java/io/agentscope/examples/responses/ResponsesWebApplication.java b/agentscope-examples/responses-web/src/main/java/io/agentscope/examples/responses/ResponsesWebApplication.java new file mode 100644 index 000000000..2718fc410 --- /dev/null +++ b/agentscope-examples/responses-web/src/main/java/io/agentscope/examples/responses/ResponsesWebApplication.java @@ -0,0 +1,272 @@ +/* + * 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.examples.responses; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Minimal Spring Boot application demonstrating how to use {@code + * agentscope-responses-web-starter}. + * + *

After starting this app, you can call: + * + *

Non-streaming request (stream=false or omitted): + * + *

+ * curl -X POST http://localhost:8080/v1/responses \\
+ *   -H 'Content-Type: application/json' \\
+ *   -d '{
+ *     "model": "qwen3-max",
+ *     "stream": false,
+ *     "input": "Hello, can you briefly introduce AgentScope Java?"
+ *   }'
+ * 
+ * + *

Streaming request (stream=true, Accept header is optional): + * + *

+ * curl -N -X POST http://localhost:8080/v1/responses \\
+ *   -H 'Content-Type: application/json' \\
+ *   -d '{
+ *     "model": "qwen3-max",
+ *     "stream": true,
+ *     "input": "Please provide a streamed answer: Describe AgentScope Java in three sentences."
+ *   }'
+ * 
+ * + *

JSON Schema structured output is supported in both non-streaming and streaming modes through + * {@code text.format.type=json_schema}. Stateful Responses and Conversations endpoints are also + * exposed by the starter. + */ +@SpringBootApplication +public class ResponsesWebApplication { + + /** + * Start the example application and print manual verification commands. + * + * @param args Spring Boot command-line arguments + */ + public static void main(String[] args) { + SpringApplication.run(ResponsesWebApplication.class, args); + printStartupInfo(); + } + + /** Print curl examples that cover the main Responses and Conversations API paths. */ + private static void printStartupInfo() { + System.out.println("\n=== Responses API Spring Web Example Application Started ==="); + System.out.println("\nBase URL: http://localhost:8080"); + System.out.println( + "Tip: pipe JSON responses to `python3 -m json.tool` for readable output."); + printSection( + "Non-streaming response", + """ + curl -s -X POST http://localhost:8080/v1/responses \\ + -H 'Content-Type: application/json' \\ + -d '{ + "model": "qwen3-max", + "input": "Please describe AgentScope Java in three sentences.", + "store": true + }' | tee /tmp/response.json | python3 -m json.tool + """); + printSection( + "Stored response and previous_response_id", + """ + RESP_ID=$(python3 -c 'import json; print(json.load(open("/tmp/response.json"))["id"])') + + curl -s -X POST http://localhost:8080/v1/responses \\ + -H 'Content-Type: application/json' \\ + -d "{ + \\"model\\": \\"qwen3-max\\", + \\"previous_response_id\\": \\"${RESP_ID}\\", + \\"input\\": \\"Continue from the previous response in one sentence.\\" + }" | python3 -m json.tool + """); + printSection( + "Streaming response", + """ + curl -N -X POST http://localhost:8080/v1/responses \\ + -H 'Content-Type: application/json' \\ + -H 'Accept: text/event-stream' \\ + -d '{ + "model": "qwen3-max", + "stream": true, + "input": "Please provide a streamed answer in three short sentences." + }' + """); + System.out.println( + "Note: Responses streams end with response.completed and do not send [DONE]."); + printSection( + "JSON Schema structured output", + """ + curl -s -X POST http://localhost:8080/v1/responses \\ + -H 'Content-Type: application/json' \\ + -d '{ + "model": "qwen3-max", + "input": "Extract the city and weather from: Hangzhou is hot today.", + "text": { + "format": { + "type": "json_schema", + "name": "weather_extract", + "strict": true, + "schema": { + "type": "object", + "properties": { + "city": { "type": "string" }, + "weather": { "type": "string" } + }, + "required": ["city", "weather"], + "additionalProperties": false + } + } + } + }' | tee /tmp/schema-response.json | python3 -m json.tool + """); + printSection( + "Structured output streaming", + """ + curl -N -X POST http://localhost:8080/v1/responses \\ + -H 'Content-Type: application/json' \\ + -H 'Accept: text/event-stream' \\ + -d '{ + "model": "qwen3-max", + "stream": true, + "input": "Extract the city and weather from: Hangzhou is hot today.", + "text": { + "format": { + "type": "json_schema", + "name": "weather_extract", + "schema": { + "type": "object", + "properties": { + "city": { "type": "string" }, + "weather": { "type": "string" } + }, + "required": ["city", "weather"], + "additionalProperties": false + } + } + } + }' + """); + printSection( + "Background response, retrieve, cancel, and delete", + """ + curl -s -X POST http://localhost:8080/v1/responses \\ + -H 'Content-Type: application/json' \\ + -d '{ + "model": "qwen3-max", + "background": true, + "store": true, + "input": "Write a longer AgentScope Java overview." + }' | tee /tmp/background-response.json | python3 -m json.tool + + BG_ID=$(python3 -c 'import json; print(json.load(open("/tmp/background-response.json"))["id"])') + curl -s "http://localhost:8080/v1/responses/${BG_ID}" | python3 -m json.tool + curl -s -X POST "http://localhost:8080/v1/responses/${BG_ID}/cancel" | python3 -m json.tool + curl -s -X DELETE "http://localhost:8080/v1/responses/${BG_ID}" | python3 -m json.tool + """); + printSection( + "Conversation state and items", + """ + curl -s -X POST http://localhost:8080/v1/conversations \\ + -H 'Content-Type: application/json' \\ + -d '{"metadata":{"user":"alice"}}' \\ + | tee /tmp/conversation.json | python3 -m json.tool + + CONV_ID=$(python3 -c 'import json; print(json.load(open("/tmp/conversation.json"))["id"])') + curl -s -X POST http://localhost:8080/v1/responses \\ + -H 'Content-Type: application/json' \\ + -d "{ + \\"model\\": \\"qwen3-max\\", + \\"conversation\\": \\"${CONV_ID}\\", + \\"input\\": \\"Add this message to the conversation.\\", + \\"store\\": true + }" | python3 -m json.tool + + curl -s "http://localhost:8080/v1/conversations/${CONV_ID}/items?limit=10&order=asc" \\ + | python3 -m json.tool + """); + printSection( + "Multimodal and file-reference input", + """ + curl -s -X POST http://localhost:8080/v1/responses \\ + -H 'Content-Type: application/json' \\ + -d '{ + "model": "qwen3-max", + "input": [{ + "type": "message", + "role": "user", + "content": [ + { "type": "input_text", "text": "Describe this image." }, + { + "type": "input_image", + "image_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Fronalpstock_big.jpg/640px-Fronalpstock_big.jpg" + }, + { "type": "input_file", "file_id": "file_test_123" } + ] + }] + }' | python3 -m json.tool + """); + System.out.println( + "Note: image/audio understanding depends on the selected multimodal model and" + + " model adapter."); + System.out.println( + " file_id inputs require an application file service for real file content" + + " parsing."); + printSection( + "External function tool loop", + """ + curl -s -X POST http://localhost:8080/v1/responses \\ + -H 'Content-Type: application/json' \\ + -d '{ + "model": "qwen3-max", + "input": "Call get_weather for Hangzhou, then wait for the tool result.", + "tools": [{ + "type": "function", + "name": "get_weather", + "description": "Get the current weather for a city", + "parameters": { + "type": "object", + "properties": { + "city": { "type": "string", "description": "The city name" } + }, + "required": ["city"], + "additionalProperties": false + }, + "strict": true + }], + "tool_choice": { "type": "function", "name": "get_weather" }, + "store": true + }' | tee /tmp/tool-call-response.json | python3 -m json.tool + """); + System.out.println( + "Note: request-level tools are schema-only external tools. Execute the tool in" + + " the client,"); + System.out.println(" then send a function_call_output item in the next request."); + System.out.println( + " To execute Java methods inside the backend, register @Tool methods in the" + + " application Toolkit."); + System.out.println("==================================================="); + } + + /** Print one titled command section in the startup banner. */ + private static void printSection(String title, String command) { + System.out.println("\n==================================================="); + System.out.println(title + "\n"); + System.out.println(command); + } +} diff --git a/agentscope-examples/responses-web/src/main/resources/application.yml b/agentscope-examples/responses-web/src/main/resources/application.yml new file mode 100644 index 000000000..acd6724a5 --- /dev/null +++ b/agentscope-examples/responses-web/src/main/resources/application.yml @@ -0,0 +1,45 @@ +# 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. +server: + port: 8080 + +logging: + level: + root: INFO + io.agentscope: INFO + +agentscope: + model: + provider: dashscope + + dashscope: + enabled: true + api-key: ${DASHSCOPE_API_KEY:YOUR_DASHSCOPE_API_KEY_HERE} + model-name: qwen3-max + stream: true + + agent: + enabled: true + name: "ResponsesAgent" + sys-prompt: | + You are a helpful assistant built with AgentScope Java. + Answer in Chinese when the user speaks Chinese, otherwise answer in English. + max-iters: 8 + + responses: + enabled: true + base-path: /v1/responses + + conversations: + base-path: /v1/conversations diff --git a/agentscope-extensions/agentscope-extensions-responses-web/pom.xml b/agentscope-extensions/agentscope-extensions-responses-web/pom.xml new file mode 100644 index 000000000..194803e59 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/pom.xml @@ -0,0 +1,74 @@ + + + + + 4.0.0 + + io.agentscope + agentscope-extensions + ${revision} + ../pom.xml + + + agentscope-extensions-responses-web + AgentScope Java - Extensions - Responses Web + AgentScope Extensions - OpenAI Responses API Web Protocol Support + + + + io.agentscope + agentscope-core + provided + true + + + io.projectreactor + reactor-core + provided + true + + + + io.projectreactor + reactor-test + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + org.junit.jupiter + junit-jupiter-api + + + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/builder/ResponsesResponseBuilder.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/builder/ResponsesResponseBuilder.java new file mode 100644 index 000000000..951a0d612 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/builder/ResponsesResponseBuilder.java @@ -0,0 +1,316 @@ +/* + * 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.responses.builder; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatUsage; +import io.agentscope.core.responses.converter.ResponsesValidationException; +import io.agentscope.core.responses.model.ResponsesError; +import io.agentscope.core.responses.model.ResponsesErrorResponse; +import io.agentscope.core.responses.model.ResponsesOutputItem; +import io.agentscope.core.responses.model.ResponsesRequest; +import io.agentscope.core.responses.model.ResponsesResponse; +import io.agentscope.core.responses.model.ResponsesTextConfig; +import io.agentscope.core.responses.model.ResponsesUsage; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Builds Responses API response objects from AgentScope messages. */ +public class ResponsesResponseBuilder { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * Build a completed non-streaming Responses object from the final assistant message. + * + * @param request Original Responses request + * @param reply Final AgentScope assistant message + * @param responseId Response ID to expose to the client + * @return Completed Responses API response + */ + public ResponsesResponse buildResponse(ResponsesRequest request, Msg reply, String responseId) { + ResponsesResponse response = baseResponse(request, responseId, "completed"); + List output = outputItems(reply); + response.setOutput(output); + response.setOutputText(outputText(output)); + response.setUsage(usage(reply)); + return response; + } + + /** + * Build a completed Responses object for {@code text.format.type=json_schema}. + * + *

AgentScope stores structured output separately from text content, while the Responses API + * returns the structured payload as an output text part. This method bridges that shape. + * + * @param request Original Responses request + * @param reply Final AgentScope assistant message containing structured data + * @param responseId Response ID to expose to the client + * @return Completed or failed Responses API response + */ + public ResponsesResponse buildStructuredResponse( + ResponsesRequest request, Msg reply, String responseId) { + ResponsesResponse response = baseResponse(request, responseId, "completed"); + String json; + try { + json = OBJECT_MAPPER.writeValueAsString(reply.getStructuredData(false)); + } catch (Exception e) { + return buildFailedResponse( + request, + ResponsesError.invalidRequest( + "Structured output was requested but no structured data was returned", + "text.format.schema", + "invalid_response"), + responseId); + } + List output = + List.of(ResponsesOutputItem.message(messageId(responseId), json)); + response.setOutput(output); + response.setOutputText(json); + response.setUsage(usage(reply)); + return response; + } + + /** + * Build the terminal {@code response.completed} payload for streaming requests. + * + * @param request Original Responses request + * @param responseId Response ID shared by the stream + * @param output Output items accumulated from streamed events + * @param outputText Concatenated assistant text + * @param reply Terminal AgentScope message, used for token usage if present + * @return Completed Responses API response + */ + public ResponsesResponse buildStreamingCompletedResponse( + ResponsesRequest request, + String responseId, + List output, + String outputText, + Msg reply) { + ResponsesResponse response = baseResponse(request, responseId, "completed"); + response.setOutput(output != null ? output : List.of()); + response.setOutputText(outputText != null ? outputText : ""); + response.setUsage(usage(reply)); + return response; + } + + /** + * Build a failed response from an arbitrary runtime error. + * + * @param request Original Responses request + * @param error Runtime error + * @param responseId Response ID to expose to the client + * @return Failed Responses API response + */ + public ResponsesResponse buildFailedResponse( + ResponsesRequest request, Throwable error, String responseId) { + String message = error != null ? error.getMessage() : "Unknown error occurred"; + return buildFailedResponse( + request, ResponsesError.invalidRequest(message, null, "runtime_error"), responseId); + } + + /** + * Build a failed response from a pre-shaped Responses error payload. + * + * @param request Original Responses request + * @param error Responses API error metadata + * @param responseId Response ID to expose to the client + * @return Failed Responses API response + */ + public ResponsesResponse buildFailedResponse( + ResponsesRequest request, ResponsesError error, String responseId) { + ResponsesResponse response = baseResponse(request, responseId, "failed"); + response.setError(error); + response.setOutput(List.of()); + response.setOutputText(""); + return response; + } + + /** + * Build the standard HTTP error wrapper for validation failures. + * + * @param error Validation exception with Responses-style metadata + * @return Error wrapper suitable for a 4xx response body + */ + public ResponsesErrorResponse buildErrorResponse(ResponsesValidationException error) { + return new ResponsesErrorResponse( + ResponsesError.invalidRequest( + error.getMessage(), error.getParam(), error.getCode())); + } + + /** + * Build the standard HTTP error wrapper for generic invalid requests. + * + * @param error Runtime error + * @return Error wrapper suitable for a 400 response body + */ + public ResponsesErrorResponse buildInvalidRequestError(Throwable error) { + String message = error != null ? error.getMessage() : "Invalid request"; + return new ResponsesErrorResponse( + ResponsesError.invalidRequest(message, null, "invalid_request")); + } + + /** + * Create a base response object with request metadata copied into the public response shape. + * + * @param request Original Responses request + * @param responseId Response ID to expose to the client + * @param status Responses lifecycle status + * @return Base Responses API response + */ + public ResponsesResponse baseResponse( + ResponsesRequest request, String responseId, String status) { + ResponsesResponse response = new ResponsesResponse(); + response.setId(responseId); + response.setObject("response"); + response.setCreatedAt(Instant.now().getEpochSecond()); + response.setStatus(status); + response.setModel(request != null ? request.getModel() : null); + response.setInstructions(request != null ? request.getInstructions() : null); + response.setStore(request == null || !Boolean.FALSE.equals(request.getStore())); + response.setPreviousResponseId(request != null ? request.getPreviousResponseId() : null); + response.setConversation(request != null ? request.getConversation() : null); + response.setBackground(request != null ? request.getBackground() : null); + response.setMetadata(request != null ? request.getMetadata() : null); + response.setText(textConfig(request)); + response.setToolChoice( + request != null && request.getToolChoice() != null + ? request.getToolChoice() + : "auto"); + response.setTools(request != null ? request.getTools() : null); + return response; + } + + private ResponsesTextConfig textConfig(ResponsesRequest request) { + if (request != null && request.getText() != null) { + return request.getText(); + } + ResponsesTextConfig config = new ResponsesTextConfig(); + ResponsesTextConfig.Format format = new ResponsesTextConfig.Format(); + format.setType("text"); + config.setFormat(format); + return config; + } + + private List outputItems(Msg reply) { + if (reply == null || reply.getContent() == null) { + return List.of(ResponsesOutputItem.message("msg_empty", "")); + } + + // A single AgentScope assistant message can contain both visible text and tool-use blocks. + // Responses represents those as separate output items, so keep tool calls as siblings. + List items = new ArrayList<>(); + for (ContentBlock block : reply.getContent()) { + if (block instanceof ToolUseBlock toolUseBlock) { + items.add( + ResponsesOutputItem.functionCall( + functionCallId(toolUseBlock.getId()), + toolUseBlock.getId(), + toolUseBlock.getName(), + argumentsJson(toolUseBlock))); + } + } + + String text = text(reply); + if (!text.isEmpty() || items.isEmpty()) { + items.add(0, ResponsesOutputItem.message(messageId(reply.getId()), text)); + } + return items; + } + + private String text(Msg reply) { + if (reply == null || reply.getContent() == null) { + return ""; + } + StringBuilder builder = new StringBuilder(); + for (ContentBlock block : reply.getContent()) { + if (block instanceof TextBlock textBlock && textBlock.getText() != null) { + builder.append(textBlock.getText()); + } + } + return builder.toString(); + } + + private String outputText(List output) { + StringBuilder builder = new StringBuilder(); + for (ResponsesOutputItem item : output) { + if (!"message".equals(item.getType()) || item.getContent() == null) { + continue; + } + item.getContent().stream() + .filter(part -> "output_text".equals(part.getType())) + .map(part -> part.getText() != null ? part.getText() : "") + .forEach(builder::append); + } + return builder.toString(); + } + + private ResponsesUsage usage(Msg reply) { + if (reply == null) { + return null; + } + ChatUsage usage = reply.getChatUsage(); + if (usage == null) { + return null; + } + return new ResponsesUsage( + usage.getInputTokens(), usage.getOutputTokens(), usage.getTotalTokens()); + } + + private String argumentsJson(ToolUseBlock block) { + // Providers differ on where they put tool arguments. Prefer the raw content string when it + // exists because it often preserves exactly what the model streamed. + if (block.getContent() != null && !block.getContent().isBlank()) { + return compactJson(block.getContent()); + } + Map input = block.getInput(); + if (input == null || input.isEmpty()) { + return "{}"; + } + try { + return OBJECT_MAPPER.writeValueAsString(input); + } catch (JsonProcessingException e) { + return "{}"; + } + } + + private String compactJson(String json) { + try { + return OBJECT_MAPPER.writeValueAsString(OBJECT_MAPPER.readTree(json)); + } catch (Exception e) { + return json; + } + } + + private String messageId(String seed) { + return seed != null && seed.startsWith("msg_") ? seed : "msg_" + normalize(seed); + } + + private String functionCallId(String seed) { + return seed != null && seed.startsWith("fc_") ? seed : "fc_" + normalize(seed); + } + + private String normalize(String seed) { + return seed == null || seed.isBlank() ? java.util.UUID.randomUUID().toString() : seed; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesConversionResult.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesConversionResult.java new file mode 100644 index 000000000..f0f8bcdba --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesConversionResult.java @@ -0,0 +1,27 @@ +/* + * 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.responses.converter; + +import com.fasterxml.jackson.databind.JsonNode; +import io.agentscope.core.message.Msg; +import java.util.List; + +/** Internal conversion result for a Responses request. */ +public record ResponsesConversionResult( + List messages, + List systemFragments, + JsonNode structuredOutputSchema, + String textFormatType) {} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesGenerationOptionsConverter.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesGenerationOptionsConverter.java new file mode 100644 index 000000000..0c9570e2b --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesGenerationOptionsConverter.java @@ -0,0 +1,84 @@ +/* + * 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.responses.converter; + +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolChoice; +import io.agentscope.core.responses.model.ResponsesRequest; + +/** Converts per-request Responses generation options to AgentScope GenerateOptions. */ +public class ResponsesGenerationOptionsConverter { + + private final ResponsesToolConverter toolConverter; + + /** + * Constructs a converter for request-level generation options. + * + * @param toolConverter Converter used for {@code tool_choice} + */ + public ResponsesGenerationOptionsConverter(ResponsesToolConverter toolConverter) { + this.toolConverter = toolConverter; + } + + /** + * Convert supported Responses generation fields into AgentScope {@link GenerateOptions}. + * + *

Returns {@code null} when the request contains no per-call generation overrides, allowing + * the agent's configured defaults to remain unchanged. + * + * @param request Original Responses request + * @return AgentScope generation options, or {@code null} if no options were requested + */ + public GenerateOptions convert(ResponsesRequest request) { + if (request == null) { + return null; + } + GenerateOptions.Builder builder = GenerateOptions.builder(); + boolean hasOptions = false; + + if (request.getModel() != null && !request.getModel().isBlank()) { + builder.modelName(request.getModel()); + hasOptions = true; + } + if (request.getStream() != null) { + builder.stream(request.getStream()); + hasOptions = true; + } + if (request.getTemperature() != null) { + builder.temperature(request.getTemperature()); + hasOptions = true; + } + if (request.getTopP() != null) { + builder.topP(request.getTopP()); + hasOptions = true; + } + if (request.getMaxOutputTokens() != null) { + builder.maxTokens(request.getMaxOutputTokens()); + hasOptions = true; + } + if (request.getReasoning() != null && request.getReasoning().getEffort() != null) { + builder.reasoningEffort(request.getReasoning().getEffort()); + hasOptions = true; + } + ToolChoice toolChoice = toolConverter.convertToolChoice(request.getToolChoice()); + if (toolChoice != null) { + builder.toolChoice(toolChoice); + hasOptions = true; + } + + return hasOptions ? builder.build() : null; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesInputConverter.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesInputConverter.java new file mode 100644 index 000000000..60e60ac05 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesInputConverter.java @@ -0,0 +1,440 @@ +/* + * 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.responses.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.message.AudioBlock; +import io.agentscope.core.message.Base64Source; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.ImageBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.message.URLSource; +import io.agentscope.core.message.VideoBlock; +import io.agentscope.core.responses.model.ResponsesRequest; +import io.agentscope.core.responses.model.ResponsesTextConfig; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Converts Responses request input into AgentScope messages. */ +public class ResponsesInputConverter { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * Convert a Responses request into AgentScope messages and structured-output metadata. + * + *

Responses input can be a string, a single item, or an array of items. This method normalizes + * those shapes into AgentScope messages while preserving instruction-like content for the + * request hook. + * + * @param request Original Responses request + * @return Converted messages plus request-scoped metadata + */ + public ResponsesConversionResult convert(ResponsesRequest request) { + validateRequest(request); + + List messages = new ArrayList<>(); + List systemFragments = new ArrayList<>(); + if (request.getInstructions() != null && !request.getInstructions().isBlank()) { + systemFragments.add(request.getInstructions()); + } + + // Keep instructions/system/developer content out of the chat message list so the hook can + // append it through AgentScope's system prompt mechanism. + convertInput(jsonNode(request.getInput()), messages, systemFragments); + if (messages.isEmpty()) { + throw ResponsesValidationException.invalid( + "At least one non-system input item is required", "input"); + } + + String textFormat = textFormatType(request); + JsonNode schema = structuredOutputSchema(request, textFormat); + return new ResponsesConversionResult( + List.copyOf(messages), List.copyOf(systemFragments), schema, textFormat); + } + + private void validateRequest(ResponsesRequest request) { + if (request == null) { + throw ResponsesValidationException.invalid("Request body is required", null); + } + if (request.getInput() == null || jsonNode(request.getInput()).isNull()) { + throw ResponsesValidationException.invalid("input is required", "input"); + } + } + + /** + * Resolve the requested text format. + * + *

{@code json_schema} maps to AgentScope structured output. {@code json_object} is rejected + * until the model adapter has an explicit JSON-object mode that is distinct from schema output. + */ + private String textFormatType(ResponsesRequest request) { + ResponsesTextConfig text = request.getText(); + if (text == null || text.getFormat() == null || text.getFormat().getType() == null) { + return "text"; + } + String type = text.getFormat().getType(); + if ("text".equals(type) || "json_schema".equals(type)) { + return type; + } + if ("json_object".equals(type)) { + throw ResponsesValidationException.unsupported( + "text.format.type=json_object is not supported in Responses API v1", + "text.format.type"); + } + throw ResponsesValidationException.unsupported( + "Unsupported text.format.type: " + type, "text.format.type"); + } + + /** Extract and validate the JSON Schema used for structured output. */ + private JsonNode structuredOutputSchema(ResponsesRequest request, String textFormat) { + if (!"json_schema".equals(textFormat)) { + return null; + } + JsonNode schema = jsonNode(request.getText().getFormat().getSchema()); + if (schema == null || !schema.isObject()) { + throw ResponsesValidationException.invalid( + "text.format.schema must be a JSON object", "text.format.schema"); + } + return schema; + } + + private void convertInput(JsonNode input, List messages, List systemFragments) { + if (input.isTextual()) { + messages.add(userMessage(List.of(text(input.asText())))); + return; + } + if (input.isArray()) { + for (int i = 0; i < input.size(); i++) { + convertInputItem(input.get(i), messages, systemFragments, "input[" + i + "]"); + } + return; + } + if (input.isObject()) { + convertInputItem(input, messages, systemFragments, "input"); + return; + } + throw ResponsesValidationException.invalid( + "input must be a string, object, or array", "input"); + } + + private void convertInputItem( + JsonNode item, List messages, List systemFragments, String param) { + if (item == null || !item.isObject()) { + throw ResponsesValidationException.invalid("Input item must be an object", param); + } + String type = textValue(item.get("type")); + if ("function_call".equals(type)) { + messages.add(functionCallMessage(item, param)); + return; + } + if ("function_call_output".equals(type)) { + messages.add(functionCallOutputMessage(item, param)); + return; + } + if (type != null && !"message".equals(type)) { + // New official Responses item types can arrive before AgentScope has a native block for + // them. Preserve the raw JSON as text so context is not silently dropped. + messages.add(opaqueItemMessage(item, type)); + return; + } + + String role = textValue(item.get("role")); + if (role == null || role.isBlank()) { + throw ResponsesValidationException.invalid("Input message role is required", param); + } + List content = contentBlocks(item.get("content"), param + ".content"); + switch (role) { + case "user" -> messages.add(Msg.builder().role(MsgRole.USER).content(content).build()); + case "assistant" -> + messages.add(Msg.builder().role(MsgRole.ASSISTANT).content(content).build()); + case "system", "developer" -> + // AgentScope has a dedicated system prompt path. Developer messages map there + // too because both roles are instruction-like in the Responses API. + systemFragments.add(textOnly(content, param)); + default -> + throw ResponsesValidationException.unsupported( + "Unsupported input message role: " + role, param + ".role"); + } + } + + private Msg userMessage(List content) { + return Msg.builder().role(MsgRole.USER).content(content).build(); + } + + private Msg functionCallMessage(JsonNode item, String param) { + String callId = textValue(item.get("call_id")); + if (callId == null || callId.isBlank()) { + throw ResponsesValidationException.invalid("function_call.call_id is required", param); + } + String name = textValue(item.get("name")); + if (name == null || name.isBlank()) { + throw ResponsesValidationException.invalid("function_call.name is required", param); + } + JsonNode argumentsNode = item.get("arguments"); + String arguments = argumentsAsString(argumentsNode); + // Keep both parsed and raw argument forms. The parsed map is convenient for AgentScope + // tools, while the raw string preserves provider-specific JSON exactly for Responses IO. + ToolUseBlock block = + ToolUseBlock.builder() + .id(callId) + .name(name) + .input(argumentsAsMap(argumentsNode)) + .content(arguments) + .build(); + return Msg.builder().role(MsgRole.ASSISTANT).content(block).build(); + } + + private Msg functionCallOutputMessage(JsonNode item, String param) { + String callId = textValue(item.get("call_id")); + if (callId == null || callId.isBlank()) { + throw ResponsesValidationException.invalid( + "function_call_output.call_id is required", param); + } + List output = contentBlocks(item.get("output"), param + ".output"); + return Msg.builder() + .role(MsgRole.TOOL) + .content(ToolResultBlock.builder().id(callId).output(output).build()) + .build(); + } + + private List contentBlocks(JsonNode content, String param) { + if (content == null || content.isNull()) { + return List.of(text("")); + } + if (content.isTextual()) { + return List.of(text(content.asText())); + } + if (content.isObject()) { + return List.of(contentPart(content, param)); + } + if (!content.isArray()) { + throw ResponsesValidationException.invalid( + "Content must be a string, object, or array", param); + } + List blocks = new ArrayList<>(); + for (int i = 0; i < content.size(); i++) { + blocks.add(contentPart(content.get(i), param + "[" + i + "]")); + } + return blocks; + } + + private ContentBlock contentPart(JsonNode part, String param) { + if (part == null || !part.isObject()) { + throw ResponsesValidationException.invalid("Content part must be an object", param); + } + String type = textValue(part.get("type")); + if ("input_text".equals(type) || "output_text".equals(type)) { + return text(textValue(part.get("text"))); + } + if ("input_image".equals(type)) { + if (part.hasNonNull("file_id")) { + // file_id is a reference to an application file service. Without that service in + // the generic starter, preserve it as opaque text instead of pretending to load it. + return text(opaquePartText(part)); + } + String imageUrl = textValue(part.get("image_url")); + if (imageUrl == null || imageUrl.isBlank()) { + throw ResponsesValidationException.invalid( + "input_image.image_url is required", param + ".image_url"); + } + return image(imageUrl, param + ".image_url"); + } + if ("input_audio".equals(type)) { + ContentBlock audio = audio(part); + // Unknown audio shapes are still retained as opaque text so future official fields do + // not disappear from conversation context. + return audio != null ? audio : text(opaquePartText(part)); + } + if ("input_video".equals(type)) { + ContentBlock video = video(part); + // Same fallback as audio: accept the item shape, but only native URL/base64 fields + // become AgentScope media blocks. + return video != null ? video : text(opaquePartText(part)); + } + if ("input_file".equals(type) || type == null) { + return text(opaquePartText(part)); + } + return text(opaquePartText(part)); + } + + private Msg opaqueItemMessage(JsonNode item, String type) { + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(text("Responses item " + type + ": " + toJson(item))) + .build(); + } + + private TextBlock text(String value) { + return TextBlock.builder().text(value != null ? value : "").build(); + } + + private ImageBlock image(String imageUrl, String param) { + if (imageUrl.startsWith("data:")) { + int comma = imageUrl.indexOf(','); + int separator = imageUrl.indexOf(";base64,"); + if (separator <= 5 || comma < 0 || separator + 7 != comma) { + throw ResponsesValidationException.invalid("Invalid image data URL", param); + } + String mediaType = imageUrl.substring(5, separator); + String data = imageUrl.substring(comma + 1); + return ImageBlock.builder() + .source(Base64Source.builder().mediaType(mediaType).data(data).build()) + .build(); + } + return ImageBlock.builder().source(URLSource.builder().url(imageUrl).build()).build(); + } + + private ContentBlock audio(JsonNode part) { + JsonNode audio = objectOrSelf(part.get("input_audio"), part); + String url = firstText(audio, "audio_url", "url"); + if (url != null && !url.isBlank()) { + return AudioBlock.builder().source(URLSource.builder().url(url).build()).build(); + } + String data = firstText(audio, "data", "audio_data"); + if (data != null && !data.isBlank()) { + String format = firstText(audio, "format"); + return AudioBlock.builder() + .source( + Base64Source.builder() + .mediaType(mediaType("audio", format)) + .data(data) + .build()) + .build(); + } + return null; + } + + private ContentBlock video(JsonNode part) { + JsonNode video = objectOrSelf(part.get("input_video"), part); + String url = firstText(video, "video_url", "url"); + if (url != null && !url.isBlank()) { + return VideoBlock.builder().source(URLSource.builder().url(url).build()).build(); + } + String data = firstText(video, "data", "video_data"); + if (data != null && !data.isBlank()) { + String format = firstText(video, "format"); + return VideoBlock.builder() + .source( + Base64Source.builder() + .mediaType(mediaType("video", format)) + .data(data) + .build()) + .build(); + } + return null; + } + + private String opaquePartText(JsonNode part) { + String type = textValue(part.get("type")); + return "Responses content part " + (type != null ? type : "unknown") + ": " + toJson(part); + } + + private JsonNode objectOrSelf(JsonNode candidate, JsonNode fallback) { + return candidate != null && candidate.isObject() ? candidate : fallback; + } + + private String firstText(JsonNode node, String... fields) { + if (node == null || !node.isObject()) { + return null; + } + for (String field : fields) { + String value = textValue(node.get(field)); + if (value != null) { + return value; + } + } + return null; + } + + private String mediaType(String family, String format) { + return family + "/" + (format != null && !format.isBlank() ? format : "octet-stream"); + } + + private String textOnly(List content, String param) { + StringBuilder builder = new StringBuilder(); + for (ContentBlock block : content) { + if (block instanceof TextBlock textBlock) { + if (!builder.isEmpty()) { + builder.append('\n'); + } + builder.append(textBlock.getText()); + } else { + throw ResponsesValidationException.unsupported( + "system/developer messages support text content only", param + ".content"); + } + } + return builder.toString(); + } + + private String argumentsAsString(JsonNode node) { + if (node == null || node.isNull()) { + return "{}"; + } + if (node.isTextual()) { + return node.asText(); + } + try { + return OBJECT_MAPPER.writeValueAsString(node); + } catch (JsonProcessingException e) { + return "{}"; + } + } + + private Map argumentsAsMap(JsonNode node) { + if (node == null || node.isNull()) { + return Map.of(); + } + try { + JsonNode parsed = node; + if (node.isTextual()) { + // Responses function_call.arguments is often a JSON string, while ToolUseBlock + // expects a parsed input map for local tool execution. + parsed = OBJECT_MAPPER.readTree(node.asText()); + } + if (!parsed.isObject()) { + return Map.of(); + } + return OBJECT_MAPPER.convertValue(parsed, new TypeReference<>() {}); + } catch (Exception e) { + return Map.of(); + } + } + + private String textValue(JsonNode node) { + return node != null && node.isTextual() ? node.asText() : null; + } + + private JsonNode jsonNode(Object value) { + return OBJECT_MAPPER.valueToTree(value); + } + + private String toJson(JsonNode node) { + try { + return OBJECT_MAPPER.writeValueAsString(node); + } catch (JsonProcessingException e) { + return String.valueOf(node); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesToolConverter.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesToolConverter.java new file mode 100644 index 000000000..cdee573da --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesToolConverter.java @@ -0,0 +1,147 @@ +/* + * 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.responses.converter; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.model.ToolChoice; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.responses.model.ResponsesTool; +import java.util.ArrayList; +import java.util.List; + +/** Converts Responses tools and tool choices to AgentScope model types. */ +public class ResponsesToolConverter { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * Convert request-level Responses tools into AgentScope tool schemas. + * + *

Only {@code type=function} tools are executable by the local AgentScope toolkit. OpenAI + * hosted-tool request shapes are accepted by the DTO layer, but this compatibility starter does + * not execute OpenAI hosted services by default. + * + * @param tools Responses tool declarations + * @return AgentScope tool schemas for function tools + */ + public List convertToToolSchemas(List tools) { + if (tools == null || tools.isEmpty()) { + return List.of(); + } + List schemas = new ArrayList<>(); + for (int i = 0; i < tools.size(); i++) { + ToolSchema schema = convertToToolSchema(tools.get(i), "tools[" + i + "]"); + if (schema != null) { + schemas.add(schema); + } + } + return schemas; + } + + /** + * Convert one Responses function tool into an AgentScope {@link ToolSchema}. + * + * @param tool Responses tool declaration + * @param param Request parameter path used in validation errors + * @return Converted schema, or {@code null} for non-function tools + */ + public ToolSchema convertToToolSchema(ResponsesTool tool, String param) { + if (tool == null) { + throw ResponsesValidationException.invalid("Tool entry must not be null", param); + } + if (!"function".equals(tool.getType())) { + return null; + } + if (tool.getName() == null || tool.getName().isBlank()) { + throw ResponsesValidationException.invalid("Function tool name is required", param); + } + JsonNode parameters = jsonNode(tool.getParameters()); + if (parameters != null && !parameters.isNull() && !parameters.isObject()) { + throw ResponsesValidationException.invalid( + "Function tool parameters must be a JSON object", param + ".parameters"); + } + + ToolSchema.Builder builder = + ToolSchema.builder() + .name(tool.getName()) + .description(tool.getDescription() != null ? tool.getDescription() : ""); + if (parameters != null && !parameters.isNull()) { + builder.parameters(OBJECT_MAPPER.convertValue(parameters, new TypeReference<>() {})); + } + if (tool.getStrict() != null) { + builder.strict(tool.getStrict()); + } + return builder.build(); + } + + /** + * Convert Responses {@code tool_choice} into AgentScope's tool-choice model. + * + *

The Responses API accepts both string values such as {@code auto} and object values such + * as {@code {"type":"function","name":"get_weather"}}. AgentScope represents those as a + * typed {@link ToolChoice} hierarchy. + * + * @param value Raw tool choice from the request DTO + * @return Converted tool choice, or {@code null} when no local mapping is needed + */ + public ToolChoice convertToolChoice(Object value) { + JsonNode toolChoice = jsonNode(value); + if (toolChoice == null || toolChoice.isNull()) { + return null; + } + if (toolChoice.isTextual()) { + return switch (toolChoice.asText()) { + case "auto" -> new ToolChoice.Auto(); + case "none" -> new ToolChoice.None(); + case "required" -> new ToolChoice.Required(); + default -> + throw ResponsesValidationException.unsupported( + "Unsupported tool_choice value: " + toolChoice.asText(), + "tool_choice"); + }; + } + if (!toolChoice.isObject()) { + throw ResponsesValidationException.invalid( + "tool_choice must be a string or object", "tool_choice"); + } + String type = text(toolChoice.get("type")); + if (!"function".equals(type)) { + return null; + } + String name = text(toolChoice.get("name")); + if (name == null || name.isBlank()) { + JsonNode function = toolChoice.get("function"); + if (function != null && function.isObject()) { + name = text(function.get("name")); + } + } + if (name == null || name.isBlank()) { + throw ResponsesValidationException.invalid( + "Function tool_choice requires a non-empty name", "tool_choice.name"); + } + return new ToolChoice.Specific(name); + } + + private String text(JsonNode node) { + return node != null && node.isTextual() ? node.asText() : null; + } + + private JsonNode jsonNode(Object value) { + return value == null ? null : OBJECT_MAPPER.valueToTree(value); + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesValidationException.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesValidationException.java new file mode 100644 index 000000000..555db7ca2 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/converter/ResponsesValidationException.java @@ -0,0 +1,59 @@ +/* + * 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.responses.converter; + +/** Validation exception carrying Responses-style error metadata. */ +public class ResponsesValidationException extends IllegalArgumentException { + + private final String param; + private final String code; + + public ResponsesValidationException(String message, String param, String code) { + super(message); + this.param = param; + this.code = code; + } + + /** + * Create an {@code invalid_request} exception for malformed request data. + * + * @param message Human-readable error message + * @param param Request parameter path associated with the error + * @return Validation exception carrying Responses-style metadata + */ + public static ResponsesValidationException invalid(String message, String param) { + return new ResponsesValidationException(message, param, "invalid_request"); + } + + /** + * Create an {@code unsupported_parameter} exception for valid shapes that are not mapped yet. + * + * @param message Human-readable error message + * @param param Request parameter path associated with the error + * @return Validation exception carrying Responses-style metadata + */ + public static ResponsesValidationException unsupported(String message, String param) { + return new ResponsesValidationException(message, param, "unsupported_parameter"); + } + + public String getParam() { + return param; + } + + public String getCode() { + return code; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/hook/ResponsesRequestHook.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/hook/ResponsesRequestHook.java new file mode 100644 index 000000000..b1489ad71 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/hook/ResponsesRequestHook.java @@ -0,0 +1,81 @@ +/* + * 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.responses.hook; + +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PreCallEvent; +import io.agentscope.core.hook.PreReasoningEvent; +import io.agentscope.core.model.GenerateOptions; +import java.util.List; +import reactor.core.publisher.Mono; + +/** Request-scoped hook for Responses system fragments and generation options. */ +public class ResponsesRequestHook implements Hook { + + private final List systemFragments; + private final GenerateOptions requestOptions; + + /** + * Constructs a hook for one Responses request. + * + * @param systemFragments Instructions, system messages, and developer messages to append to + * the model call + * @param requestOptions Generation options derived from the request body + */ + public ResponsesRequestHook(List systemFragments, GenerateOptions requestOptions) { + this.systemFragments = systemFragments != null ? List.copyOf(systemFragments) : List.of(); + this.requestOptions = requestOptions; + } + + /** + * Apply request-scoped instructions and generation options to AgentScope lifecycle events. + * + *

{@link PreCallEvent} is used for system content, while {@link PreReasoningEvent} is used + * for model options so application defaults and per-request overrides can be merged. + * + * @param event AgentScope hook event + * @return The same event after mutation + */ + @Override + public Mono onEvent(T event) { + if (event instanceof PreCallEvent preCallEvent) { + for (String fragment : systemFragments) { + if (fragment != null && !fragment.isBlank()) { + preCallEvent.appendSystemContent(fragment); + } + } + return Mono.just(event); + } + if (event instanceof PreReasoningEvent preReasoningEvent && requestOptions != null) { + GenerateOptions merged = + GenerateOptions.mergeOptions( + requestOptions, preReasoningEvent.getEffectiveGenerateOptions()); + preReasoningEvent.setGenerateOptions(merged); + return Mono.just(event); + } + return Mono.just(event); + } + + /** + * Run after core hooks with lower priority, while still leaving room for application hooks to + * override this starter if they use a higher priority. + */ + @Override + public int priority() { + return 50; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesContentPart.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesContentPart.java new file mode 100644 index 000000000..7857fa7d6 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesContentPart.java @@ -0,0 +1,70 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +/** Content part in a Responses output item or streaming event. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesContentPart { + + private String type; + private String text; + private List annotations; + + public ResponsesContentPart() {} + + public ResponsesContentPart(String type, String text) { + this.type = type; + this.text = text; + this.annotations = List.of(); + } + + /** + * Create a Responses {@code output_text} content part. + * + * @param text Output text + * @return Content part with an empty annotations list + */ + public static ResponsesContentPart outputText(String text) { + return new ResponsesContentPart("output_text", text != null ? text : ""); + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public List getAnnotations() { + return annotations; + } + + public void setAnnotations(List annotations) { + this.annotations = annotations; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesConversation.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesConversation.java new file mode 100644 index 000000000..fc4aff7a6 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesConversation.java @@ -0,0 +1,66 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Stateful conversation object for the OpenAI Conversations-compatible API. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesConversation { + + private String id; + private String object = "conversation"; + + @JsonProperty("created_at") + private long createdAt; + + private Map metadata = new LinkedHashMap<>(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getObject() { + return object; + } + + public void setObject(String object) { + this.object = object; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata != null ? new LinkedHashMap<>(metadata) : new LinkedHashMap<>(); + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesConversationItemsRequest.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesConversationItemsRequest.java new file mode 100644 index 000000000..772472465 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesConversationItemsRequest.java @@ -0,0 +1,34 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +/** Request body for creating conversation items. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesConversationItemsRequest { + + private List items; + + public List getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesConversationRequest.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesConversationRequest.java new file mode 100644 index 000000000..5e6413351 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesConversationRequest.java @@ -0,0 +1,44 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; +import java.util.Map; + +/** Request body for creating or updating a conversation. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesConversationRequest { + + private List items; + private Map metadata; + + public List getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesDeletionStatus.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesDeletionStatus.java new file mode 100644 index 000000000..13c14f55c --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesDeletionStatus.java @@ -0,0 +1,59 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** Deletion confirmation payload for stateful Responses and Conversations resources. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesDeletionStatus { + + private String id; + private String object; + private boolean deleted; + + public ResponsesDeletionStatus() {} + + public ResponsesDeletionStatus(String id, String object, boolean deleted) { + this.id = id; + this.object = object; + this.deleted = deleted; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getObject() { + return object; + } + + public void setObject(String object) { + this.object = object; + } + + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesError.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesError.java new file mode 100644 index 000000000..a9fbc15f0 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesError.java @@ -0,0 +1,81 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** Error payload used by Responses-compatible errors and failed responses. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesError { + + private String message; + private String type; + private String param; + private String code; + + public ResponsesError() {} + + public ResponsesError(String message, String type, String param, String code) { + this.message = message; + this.type = type; + this.param = param; + this.code = code; + } + + /** + * Create the common Responses invalid-request error shape. + * + * @param message Human-readable error message + * @param param Request parameter path associated with the error + * @param code Error code + * @return Error payload + */ + public static ResponsesError invalidRequest(String message, String param, String code) { + return new ResponsesError(message, "invalid_request_error", param, code); + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getParam() { + return param; + } + + public void setParam(String param) { + this.param = param; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesErrorResponse.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesErrorResponse.java new file mode 100644 index 000000000..6109a87ee --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesErrorResponse.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.responses.model; + +/** HTTP 400 error wrapper for invalid Responses requests. */ +public class ResponsesErrorResponse { + + private ResponsesError error; + + public ResponsesErrorResponse() {} + + public ResponsesErrorResponse(ResponsesError error) { + this.error = error; + } + + public ResponsesError getError() { + return error; + } + + public void setError(ResponsesError error) { + this.error = error; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesList.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesList.java new file mode 100644 index 000000000..4fa59942e --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesList.java @@ -0,0 +1,123 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; + +/** Generic OpenAI-style list wrapper used by Responses and Conversations resources. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesList { + + private String object = "list"; + private List data; + + @JsonProperty("first_id") + private String firstId; + + @JsonProperty("last_id") + private String lastId; + + @JsonProperty("has_more") + private boolean hasMore; + + public ResponsesList() {} + + /** + * Create a list wrapper without additional pages. + * + * @param data Page data + */ + public ResponsesList(List data) { + this(data, false); + } + + /** + * Create a list wrapper and derive cursor IDs from the first and last items. + * + * @param data Page data + * @param hasMore Whether another page is available + */ + public ResponsesList(List data, boolean hasMore) { + this.data = data; + this.firstId = id(data, 0); + this.lastId = id(data, data != null ? data.size() - 1 : -1); + this.hasMore = hasMore; + } + + public String getObject() { + return object; + } + + public void setObject(String object) { + this.object = object; + } + + public List getData() { + return data; + } + + public void setData(List data) { + this.data = data; + this.firstId = id(data, 0); + this.lastId = id(data, data != null ? data.size() - 1 : -1); + } + + public String getFirstId() { + return firstId; + } + + public void setFirstId(String firstId) { + this.firstId = firstId; + } + + public String getLastId() { + return lastId; + } + + public void setLastId(String lastId) { + this.lastId = lastId; + } + + public boolean isHasMore() { + return hasMore; + } + + public void setHasMore(boolean hasMore) { + this.hasMore = hasMore; + } + + private String id(List values, int index) { + if (values == null || index < 0 || index >= values.size()) { + return null; + } + Object value = values.get(index); + if (value instanceof Map map) { + Object id = map.get("id"); + return id instanceof String text && !text.isBlank() ? text : null; + } + try { + // DTO pages expose getId(); map pages use an explicit "id" key. Supporting both keeps + // Responses and Conversations resources on one generic list wrapper. + Object id = value.getClass().getMethod("getId").invoke(value); + return id instanceof String text && !text.isBlank() ? text : null; + } catch (ReflectiveOperationException e) { + return null; + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesOutputItem.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesOutputItem.java new file mode 100644 index 000000000..3015539f6 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesOutputItem.java @@ -0,0 +1,166 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** Output item in a Responses API response. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesOutputItem { + + private String id; + private String type; + private String role; + private String status; + private List content; + + @JsonProperty("call_id") + private String callId; + + private String name; + private String arguments; + + /** + * Create a completed assistant message output item. + * + * @param id Output item ID + * @param text Assistant text + * @return Message output item + */ + public static ResponsesOutputItem message(String id, String text) { + return message(id, text, "completed"); + } + + /** + * Create an assistant message output item. + * + * @param id Output item ID + * @param text Assistant text + * @param status Item status, such as {@code in_progress} or {@code completed} + * @return Message output item + */ + public static ResponsesOutputItem message(String id, String text, String status) { + ResponsesOutputItem item = new ResponsesOutputItem(); + item.setId(id); + item.setType("message"); + item.setRole("assistant"); + item.setStatus(status); + item.setContent(List.of(ResponsesContentPart.outputText(text))); + return item; + } + + /** + * Create a completed function-call output item. + * + * @param id Output item ID + * @param callId Tool call ID used by the model and follow-up tool output + * @param name Function name + * @param arguments JSON argument string + * @return Function-call output item + */ + public static ResponsesOutputItem functionCall( + String id, String callId, String name, String arguments) { + return functionCall(id, callId, name, arguments, "completed"); + } + + /** + * Create a function-call output item. + * + * @param id Output item ID + * @param callId Tool call ID used by the model and follow-up tool output + * @param name Function name + * @param arguments JSON argument string + * @param status Item status, such as {@code in_progress} or {@code completed} + * @return Function-call output item + */ + public static ResponsesOutputItem functionCall( + String id, String callId, String name, String arguments, String status) { + ResponsesOutputItem item = new ResponsesOutputItem(); + item.setId(id); + item.setType("function_call"); + item.setCallId(callId); + item.setName(name); + item.setArguments(arguments); + item.setStatus(status); + return item; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public List getContent() { + return content; + } + + public void setContent(List content) { + this.content = content; + } + + public String getCallId() { + return callId; + } + + public void setCallId(String callId) { + this.callId = callId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getArguments() { + return arguments; + } + + public void setArguments(String arguments) { + this.arguments = arguments; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesReasoningConfig.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesReasoningConfig.java new file mode 100644 index 000000000..6ef30195e --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesReasoningConfig.java @@ -0,0 +1,33 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** Reasoning configuration accepted by the Responses API compatibility layer. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesReasoningConfig { + + private String effort; + + public String getEffort() { + return effort; + } + + public void setEffort(String effort) { + this.effort = effort; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesRequest.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesRequest.java new file mode 100644 index 000000000..17894368c --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesRequest.java @@ -0,0 +1,197 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Request payload for the OpenAI Responses-compatible HTTP API. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesRequest { + + private String model; + private Object input; + private String instructions; + private Boolean stream; + private List tools; + + @JsonProperty("tool_choice") + private Object toolChoice; + + private Double temperature; + + @JsonProperty("top_p") + private Double topP; + + @JsonProperty("max_output_tokens") + private Integer maxOutputTokens; + + private ResponsesReasoningConfig reasoning; + private ResponsesTextConfig text; + + @JsonProperty("previous_response_id") + private String previousResponseId; + + private Object conversation; + private Boolean background; + private Boolean store; + private Map metadata; + + private final Map additionalFields = new LinkedHashMap<>(); + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public Object getInput() { + return input; + } + + public void setInput(Object input) { + this.input = input; + } + + public String getInstructions() { + return instructions; + } + + public void setInstructions(String instructions) { + this.instructions = instructions; + } + + public Boolean getStream() { + return stream; + } + + public void setStream(Boolean stream) { + this.stream = stream; + } + + public List getTools() { + return tools; + } + + public void setTools(List tools) { + this.tools = tools; + } + + public Object getToolChoice() { + return toolChoice; + } + + public void setToolChoice(Object toolChoice) { + this.toolChoice = toolChoice; + } + + public Double getTemperature() { + return temperature; + } + + public void setTemperature(Double temperature) { + this.temperature = temperature; + } + + public Double getTopP() { + return topP; + } + + public void setTopP(Double topP) { + this.topP = topP; + } + + public Integer getMaxOutputTokens() { + return maxOutputTokens; + } + + public void setMaxOutputTokens(Integer maxOutputTokens) { + this.maxOutputTokens = maxOutputTokens; + } + + public ResponsesReasoningConfig getReasoning() { + return reasoning; + } + + public void setReasoning(ResponsesReasoningConfig reasoning) { + this.reasoning = reasoning; + } + + public ResponsesTextConfig getText() { + return text; + } + + public void setText(ResponsesTextConfig text) { + this.text = text; + } + + public String getPreviousResponseId() { + return previousResponseId; + } + + public void setPreviousResponseId(String previousResponseId) { + this.previousResponseId = previousResponseId; + } + + public Object getConversation() { + return conversation; + } + + public void setConversation(Object conversation) { + this.conversation = conversation; + } + + public Boolean getBackground() { + return background; + } + + public void setBackground(Boolean background) { + this.background = background; + } + + public Boolean getStore() { + return store; + } + + public void setStore(Boolean store) { + this.store = store; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + @JsonAnyGetter + public Map getAdditionalFields() { + return additionalFields; + } + + @JsonAnySetter + public void putAdditionalField(String name, Object value) { + additionalFields.put(name, value); + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesResponse.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesResponse.java new file mode 100644 index 000000000..65d064629 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesResponse.java @@ -0,0 +1,201 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; + +/** Non-streaming Responses API response object. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesResponse { + + private String id; + private String object = "response"; + + @JsonProperty("created_at") + private long createdAt; + + private String status; + private String model; + private String instructions; + private List output; + + @JsonProperty("output_text") + private String outputText; + + private Boolean store = false; + + @JsonProperty("previous_response_id") + private String previousResponseId; + + private Object conversation; + private Boolean background; + private Map metadata; + private ResponsesTextConfig text; + + @JsonProperty("tool_choice") + private Object toolChoice; + + private List tools; + private ResponsesUsage usage; + private ResponsesError error; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getObject() { + return object; + } + + public void setObject(String object) { + this.object = object; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getInstructions() { + return instructions; + } + + public void setInstructions(String instructions) { + this.instructions = instructions; + } + + public List getOutput() { + return output; + } + + public void setOutput(List output) { + this.output = output; + } + + public String getOutputText() { + return outputText; + } + + public void setOutputText(String outputText) { + this.outputText = outputText; + } + + public Boolean getStore() { + return store; + } + + public void setStore(Boolean store) { + this.store = store; + } + + public String getPreviousResponseId() { + return previousResponseId; + } + + public void setPreviousResponseId(String previousResponseId) { + this.previousResponseId = previousResponseId; + } + + public Object getConversation() { + return conversation; + } + + public void setConversation(Object conversation) { + this.conversation = conversation; + } + + public Boolean getBackground() { + return background; + } + + public void setBackground(Boolean background) { + this.background = background; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + public ResponsesTextConfig getText() { + return text; + } + + public void setText(ResponsesTextConfig text) { + this.text = text; + } + + public Object getToolChoice() { + return toolChoice; + } + + public void setToolChoice(Object toolChoice) { + this.toolChoice = toolChoice; + } + + public List getTools() { + return tools; + } + + public void setTools(List tools) { + this.tools = tools; + } + + public ResponsesUsage getUsage() { + return usage; + } + + public void setUsage(ResponsesUsage usage) { + this.usage = usage; + } + + public ResponsesError getError() { + return error; + } + + public void setError(ResponsesError error) { + this.error = error; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesStreamEvent.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesStreamEvent.java new file mode 100644 index 000000000..6a16449d3 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesStreamEvent.java @@ -0,0 +1,304 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** A single Responses API streaming event payload. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesStreamEvent { + + private String type; + + @JsonProperty("sequence_number") + private Long sequenceNumber; + + @JsonProperty("response_id") + private String responseId; + + private ResponsesResponse response; + + @JsonProperty("output_index") + private Integer outputIndex; + + @JsonProperty("content_index") + private Integer contentIndex; + + @JsonProperty("item_id") + private String itemId; + + private ResponsesOutputItem item; + + private ResponsesContentPart part; + private String delta; + private String text; + private String arguments; + + @JsonProperty("call_id") + private String callId; + + private String name; + private ResponsesError error; + + /** + * Create an event whose payload is a full response object. + * + * @param type Responses event type + * @param response Response payload + * @return Stream event + */ + public static ResponsesStreamEvent responseEvent(String type, ResponsesResponse response) { + ResponsesStreamEvent event = new ResponsesStreamEvent(); + event.setType(type); + event.setResponse(response); + return event; + } + + /** + * Create an output-item lifecycle event. + * + * @param type Responses event type + * @param outputIndex Output item index + * @param item Output item payload + * @return Stream event + */ + public static ResponsesStreamEvent outputItemEvent( + String type, Integer outputIndex, ResponsesOutputItem item) { + ResponsesStreamEvent event = new ResponsesStreamEvent(); + event.setType(type); + event.setOutputIndex(outputIndex); + event.setItem(item); + return event; + } + + /** + * Create a content-part lifecycle event. + * + * @param type Responses event type + * @param outputIndex Output item index + * @param contentIndex Content part index within the output item + * @param itemId Output item ID + * @param part Content part payload + * @return Stream event + */ + public static ResponsesStreamEvent contentPartEvent( + String type, + Integer outputIndex, + Integer contentIndex, + String itemId, + ResponsesContentPart part) { + ResponsesStreamEvent event = new ResponsesStreamEvent(); + event.setType(type); + event.setOutputIndex(outputIndex); + event.setContentIndex(contentIndex); + event.setItemId(itemId); + event.setPart(part); + return event; + } + + /** + * Create an output text delta event. + * + * @param type Responses event type + * @param outputIndex Output item index + * @param contentIndex Content part index within the output item + * @param itemId Output item ID + * @param delta Incremental text delta + * @return Stream event + */ + public static ResponsesStreamEvent textDelta( + String type, Integer outputIndex, Integer contentIndex, String itemId, String delta) { + ResponsesStreamEvent event = + contentPartEvent(type, outputIndex, contentIndex, itemId, null); + event.setDelta(delta); + return event; + } + + /** + * Create an output text done event. + * + * @param type Responses event type + * @param outputIndex Output item index + * @param contentIndex Content part index within the output item + * @param itemId Output item ID + * @param text Final accumulated text + * @return Stream event + */ + public static ResponsesStreamEvent textDone( + String type, Integer outputIndex, Integer contentIndex, String itemId, String text) { + ResponsesStreamEvent event = + contentPartEvent(type, outputIndex, contentIndex, itemId, null); + event.setText(text); + return event; + } + + /** + * Create a function-call arguments done event. + * + * @param outputIndex Output item index + * @param itemId Function-call output item ID + * @param arguments Final JSON arguments + * @return Stream event + */ + public static ResponsesStreamEvent argumentsDone( + Integer outputIndex, String itemId, String arguments) { + ResponsesStreamEvent event = new ResponsesStreamEvent(); + event.setType("response.function_call_arguments.done"); + event.setOutputIndex(outputIndex); + event.setItemId(itemId); + event.setArguments(arguments); + return event; + } + + /** + * Create a function-call arguments delta event. + * + * @param outputIndex Output item index + * @param itemId Function-call output item ID + * @param delta Incremental JSON argument text + * @return Stream event + */ + public static ResponsesStreamEvent argumentsDelta( + Integer outputIndex, String itemId, String delta) { + ResponsesStreamEvent event = new ResponsesStreamEvent(); + event.setType("response.function_call_arguments.delta"); + event.setOutputIndex(outputIndex); + event.setItemId(itemId); + event.setDelta(delta); + return event; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Long getSequenceNumber() { + return sequenceNumber; + } + + public void setSequenceNumber(Long sequenceNumber) { + this.sequenceNumber = sequenceNumber; + } + + public String getResponseId() { + return responseId; + } + + public void setResponseId(String responseId) { + this.responseId = responseId; + } + + public ResponsesResponse getResponse() { + return response; + } + + public void setResponse(ResponsesResponse response) { + this.response = response; + } + + public Integer getOutputIndex() { + return outputIndex; + } + + public void setOutputIndex(Integer outputIndex) { + this.outputIndex = outputIndex; + } + + public Integer getContentIndex() { + return contentIndex; + } + + public void setContentIndex(Integer contentIndex) { + this.contentIndex = contentIndex; + } + + public String getItemId() { + return itemId; + } + + public void setItemId(String itemId) { + this.itemId = itemId; + } + + public ResponsesOutputItem getItem() { + return item; + } + + public void setItem(ResponsesOutputItem item) { + this.item = item; + } + + public ResponsesContentPart getPart() { + return part; + } + + public void setPart(ResponsesContentPart part) { + this.part = part; + } + + public String getDelta() { + return delta; + } + + public void setDelta(String delta) { + this.delta = delta; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public String getArguments() { + return arguments; + } + + public void setArguments(String arguments) { + this.arguments = arguments; + } + + public String getCallId() { + return callId; + } + + public void setCallId(String callId) { + this.callId = callId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public ResponsesError getError() { + return error; + } + + public void setError(ResponsesError error) { + this.error = error; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesTextConfig.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesTextConfig.java new file mode 100644 index 000000000..2d92c968a --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesTextConfig.java @@ -0,0 +1,84 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** Text output configuration for Responses requests and responses. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesTextConfig { + + private Format format; + + public Format getFormat() { + return format; + } + + public void setFormat(Format format) { + this.format = format; + } + + /** Response text format definition. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Format { + + private String type; + private String name; + private String description; + private Object schema; + private Boolean strict; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + 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 Object getSchema() { + return schema; + } + + public void setSchema(Object schema) { + this.schema = schema; + } + + public Boolean getStrict() { + return strict; + } + + public void setStrict(Boolean strict) { + this.strict = strict; + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesTokenCount.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesTokenCount.java new file mode 100644 index 000000000..893f68cdf --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesTokenCount.java @@ -0,0 +1,41 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Approximate token-count response for the Responses input token counting endpoint. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesTokenCount { + + @JsonProperty("input_tokens") + private int inputTokens; + + public ResponsesTokenCount() {} + + public ResponsesTokenCount(int inputTokens) { + this.inputTokens = inputTokens; + } + + public int getInputTokens() { + return inputTokens; + } + + public void setInputTokens(int inputTokens) { + this.inputTokens = inputTokens; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesTool.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesTool.java new file mode 100644 index 000000000..fe4594d17 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesTool.java @@ -0,0 +1,69 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** Function tool declaration in Responses API format. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesTool { + + private String type; + private String name; + private String description; + private Object parameters; + private Boolean strict; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + 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 Object getParameters() { + return parameters; + } + + public void setParameters(Object parameters) { + this.parameters = parameters; + } + + public Boolean getStrict() { + return strict; + } + + public void setStrict(Boolean strict) { + this.strict = strict; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesUsage.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesUsage.java new file mode 100644 index 000000000..07a178a46 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/model/ResponsesUsage.java @@ -0,0 +1,65 @@ +/* + * 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.responses.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Token usage statistics in Responses API format. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponsesUsage { + + @JsonProperty("input_tokens") + private Integer inputTokens; + + @JsonProperty("output_tokens") + private Integer outputTokens; + + @JsonProperty("total_tokens") + private Integer totalTokens; + + public ResponsesUsage() {} + + public ResponsesUsage(Integer inputTokens, Integer outputTokens, Integer totalTokens) { + this.inputTokens = inputTokens; + this.outputTokens = outputTokens; + this.totalTokens = totalTokens; + } + + public Integer getInputTokens() { + return inputTokens; + } + + public void setInputTokens(Integer inputTokens) { + this.inputTokens = inputTokens; + } + + public Integer getOutputTokens() { + return outputTokens; + } + + public void setOutputTokens(Integer outputTokens) { + this.outputTokens = outputTokens; + } + + public Integer getTotalTokens() { + return totalTokens; + } + + public void setTotalTokens(Integer totalTokens) { + this.totalTokens = totalTokens; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/streaming/ResponsesStreamingAdapter.java b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/streaming/ResponsesStreamingAdapter.java new file mode 100644 index 000000000..ef215be2f --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/main/java/io/agentscope/core/responses/streaming/ResponsesStreamingAdapter.java @@ -0,0 +1,523 @@ +/* + * 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.responses.streaming; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.Event; +import io.agentscope.core.agent.EventType; +import io.agentscope.core.agent.StreamOptions; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.responses.builder.ResponsesResponseBuilder; +import io.agentscope.core.responses.model.ResponsesContentPart; +import io.agentscope.core.responses.model.ResponsesError; +import io.agentscope.core.responses.model.ResponsesOutputItem; +import io.agentscope.core.responses.model.ResponsesRequest; +import io.agentscope.core.responses.model.ResponsesResponse; +import io.agentscope.core.responses.model.ResponsesStreamEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import reactor.core.publisher.Flux; + +/** + * Converts AgentScope streaming events to Responses API streaming events. + * + *

This adapter is framework-agnostic. It owns the Responses API event choreography ({@code + * response.created}, {@code response.in_progress}, output item events, text deltas, function-call + * argument events, and the terminal {@code response.completed} or {@code response.failed} event), + * while Spring-specific SSE serialization lives in the starter module. + * + *

When JSON Schema structured output is requested, the adapter calls the agent's structured + * streaming path and emits the final structured payload as standard Responses output text events. + */ +public class ResponsesStreamingAdapter { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final ResponsesResponseBuilder responseBuilder; + + /** Constructs a streaming adapter with the default response builder. */ + public ResponsesStreamingAdapter() { + this(new ResponsesResponseBuilder()); + } + + /** + * Constructs a streaming adapter. + * + * @param responseBuilder Builder used for terminal response payloads + */ + public ResponsesStreamingAdapter(ResponsesResponseBuilder responseBuilder) { + this.responseBuilder = responseBuilder; + } + + /** + * Stream a normal text/tool Responses request. + * + * @param agent The agent to stream from + * @param messages Converted AgentScope messages + * @param request Original Responses request + * @param responseId Response ID shared by all stream events + * @return Responses API stream events + */ + public Flux stream( + ReActAgent agent, List messages, ResponsesRequest request, String responseId) { + return stream(agent, messages, null, request, responseId); + } + + /** + * Stream a Responses request, optionally using JSON Schema structured output. + * + *

For regular streaming, incremental reasoning events become text deltas and tool-use blocks + * become function-call events. For structured streaming, the agent returns a final structured + * result and this adapter emits it as a compact JSON text delta followed by the standard done + * and completed events. + * + * @param agent The agent to stream from + * @param messages Converted AgentScope messages + * @param structuredOutputSchema Optional JSON Schema for structured output + * @param request Original Responses request + * @param responseId Response ID shared by all stream events + * @return Responses API stream events + */ + public Flux stream( + ReActAgent agent, + List messages, + JsonNode structuredOutputSchema, + ResponsesRequest request, + String responseId) { + boolean structuredStream = structuredOutputSchema != null; + StreamOptions options = + StreamOptions.builder() + .eventTypes(streamEventTypes(structuredStream)) + .incremental(true) + .build(); + + ResponsesResponse created = responseBuilder.baseResponse(request, responseId, "created"); + ResponsesResponse inProgress = + responseBuilder.baseResponse(request, responseId, "in_progress"); + + AtomicBoolean hasTextItem = new AtomicBoolean(false); + AtomicBoolean hasTextDone = new AtomicBoolean(false); + AtomicBoolean hasSeenIncrementalReasoning = new AtomicBoolean(false); + AtomicInteger nextOutputIndex = new AtomicInteger(0); + AtomicInteger textOutputIndex = new AtomicInteger(-1); + AtomicReference terminalMessage = new AtomicReference<>(); + Map completedOutput = new TreeMap<>(); + StringBuilder accumulatedText = new StringBuilder(); + String messageId = messageId(responseId); + + // AgentScope incremental streams often include a final accumulated REASONING event. Track + // whether we saw true deltas so the final accumulated text is not duplicated. + Flux body = + agentStream(agent, messages, options, structuredOutputSchema) + .filter(event -> event.getMessage() != null) + .doOnNext( + event -> { + if (event.getType() == EventType.REASONING + && !event.isLast() + && !text(event.getMessage()).isEmpty()) { + hasSeenIncrementalReasoning.set(true); + } + }) + .concatMap( + event -> + convertEvent( + event, + nextOutputIndex, + textOutputIndex, + messageId, + hasTextItem, + hasTextDone, + hasSeenIncrementalReasoning, + accumulatedText, + completedOutput, + terminalMessage, + structuredStream)); + + Flux completion = + Flux.defer( + () -> { + List events = new ArrayList<>(); + // If the model ended without an explicit terminal text event, close + // the open content part before sending response.completed. + if (hasTextItem.get() && !hasTextDone.get()) { + events.addAll( + completeTextEvents( + textOutputIndex.get(), + messageId, + accumulatedText.toString(), + completedOutput, + hasTextDone)); + } + ResponsesResponse completed = + responseBuilder.buildStreamingCompletedResponse( + request, + responseId, + new ArrayList<>(completedOutput.values()), + accumulatedText.toString(), + terminalMessage.get()); + events.add( + ResponsesStreamEvent.responseEvent( + "response.completed", completed)); + return Flux.fromIterable(events); + }); + + Flux events = + Flux.concat( + Flux.just( + ResponsesStreamEvent.responseEvent( + "response.created", created), + ResponsesStreamEvent.responseEvent( + "response.in_progress", inProgress)), + body, + completion) + .onErrorResume( + error -> Flux.just(createFailedEvent(error, request, responseId))); + + return withStreamMetadata(events, responseId); + } + + /** + * Create a Responses {@code response.failed} event for errors that happen after streaming + * begins. + * + * @param error Runtime error + * @param request Original Responses request + * @param responseId Response ID shared by the stream + * @return Failure stream event + */ + public ResponsesStreamEvent createFailedEvent( + Throwable error, ResponsesRequest request, String responseId) { + String message = error != null ? error.getMessage() : "Unknown error occurred"; + ResponsesResponse failed = + responseBuilder.buildFailedResponse( + request, + ResponsesError.invalidRequest(message, null, "runtime_error"), + responseId); + return ResponsesStreamEvent.responseEvent("response.failed", failed); + } + + private Flux convertEvent( + Event event, + AtomicInteger nextOutputIndex, + AtomicInteger textOutputIndex, + String messageId, + AtomicBoolean hasTextItem, + AtomicBoolean hasTextDone, + AtomicBoolean hasSeenIncrementalReasoning, + StringBuilder accumulatedText, + Map completedOutput, + AtomicReference terminalMessage, + boolean structuredStream) { + Msg msg = event.getMessage(); + if (msg == null) { + return Flux.empty(); + } + if (event.isLast()) { + terminalMessage.set(msg); + } + + if (structuredStream && event.getType() == EventType.AGENT_RESULT) { + // Structured output streaming does not expose partial schema objects from AgentScope; + // emit the final structured payload as a single output_text delta. + return Flux.fromIterable( + structuredOutputEvents( + msg, + nextOutputIndex, + textOutputIndex, + messageId, + hasTextItem, + accumulatedText, + completedOutput, + hasTextDone)); + } + + if (msg.getContent() == null) { + return Flux.empty(); + } + + boolean includeText = + !(event.getType() == EventType.REASONING + && event.isLast() + && hasSeenIncrementalReasoning.get()); + List events = new ArrayList<>(); + + String text = text(msg); + if (includeText && !text.isEmpty()) { + if (hasTextItem.compareAndSet(false, true)) { + // Responses streams must announce output item and content part creation before + // emitting text deltas for that item. + int index = nextOutputIndex.getAndIncrement(); + textOutputIndex.set(index); + ResponsesOutputItem item = + ResponsesOutputItem.message(messageId, "", "in_progress"); + events.add( + ResponsesStreamEvent.outputItemEvent( + "response.output_item.added", index, item)); + events.add( + ResponsesStreamEvent.contentPartEvent( + "response.content_part.added", + index, + 0, + messageId, + ResponsesContentPart.outputText(""))); + } + accumulatedText.append(text); + events.add( + ResponsesStreamEvent.textDelta( + "response.output_text.delta", + textOutputIndex.get(), + 0, + messageId, + text)); + } + + if (event.getType() == EventType.REASONING) { + for (ContentBlock block : msg.getContent()) { + if (block instanceof ToolUseBlock toolUseBlock) { + // Tool-use blocks become Responses function_call output items. The actual Java + // tool execution is performed by the client or by AgentScope toolkit code. + int index = nextOutputIndex.getAndIncrement(); + String itemId = functionCallId(toolUseBlock.getId()); + String arguments = argumentsJson(toolUseBlock); + ResponsesOutputItem addedItem = + ResponsesOutputItem.functionCall( + itemId, + toolUseBlock.getId(), + toolUseBlock.getName(), + "", + "in_progress"); + ResponsesOutputItem completedItem = + ResponsesOutputItem.functionCall( + itemId, + toolUseBlock.getId(), + toolUseBlock.getName(), + arguments); + events.add( + ResponsesStreamEvent.outputItemEvent( + "response.output_item.added", index, addedItem)); + if (!arguments.isEmpty()) { + events.add(ResponsesStreamEvent.argumentsDelta(index, itemId, arguments)); + } + ResponsesStreamEvent argumentsDone = + ResponsesStreamEvent.argumentsDone(index, itemId, arguments); + argumentsDone.setCallId(toolUseBlock.getId()); + argumentsDone.setName(toolUseBlock.getName()); + events.add(argumentsDone); + events.add( + ResponsesStreamEvent.outputItemEvent( + "response.output_item.done", index, completedItem)); + completedOutput.put(index, completedItem); + } + } + } + + if (event.isLast() + && hasTextItem.get() + && !hasTextDone.get() + && msg.getGenerateReason() != GenerateReason.TOOL_SUSPENDED) { + events.addAll( + completeTextEvents( + textOutputIndex.get(), + messageId, + accumulatedText.toString(), + completedOutput, + hasTextDone)); + } + + return Flux.fromIterable(events); + } + + private EventType[] streamEventTypes(boolean structuredStream) { + return structuredStream + ? new EventType[] {EventType.AGENT_RESULT} + : new EventType[] {EventType.REASONING, EventType.TOOL_RESULT}; + } + + private Flux agentStream( + ReActAgent agent, + List messages, + StreamOptions options, + JsonNode structuredOutputSchema) { + return structuredOutputSchema != null + ? agent.stream(messages, options, structuredOutputSchema) + : agent.stream(messages, options); + } + + private List structuredOutputEvents( + Msg msg, + AtomicInteger nextOutputIndex, + AtomicInteger textOutputIndex, + String messageId, + AtomicBoolean hasTextItem, + StringBuilder accumulatedText, + Map completedOutput, + AtomicBoolean hasTextDone) { + String text = structuredOutputText(msg); + if (text.isEmpty()) { + return List.of(); + } + + List events = new ArrayList<>(); + if (hasTextItem.compareAndSet(false, true)) { + int index = nextOutputIndex.getAndIncrement(); + textOutputIndex.set(index); + events.add( + ResponsesStreamEvent.outputItemEvent( + "response.output_item.added", + index, + ResponsesOutputItem.message(messageId, "", "in_progress"))); + events.add( + ResponsesStreamEvent.contentPartEvent( + "response.content_part.added", + index, + 0, + messageId, + ResponsesContentPart.outputText(""))); + } + accumulatedText.append(text); + events.add( + ResponsesStreamEvent.textDelta( + "response.output_text.delta", textOutputIndex.get(), 0, messageId, text)); + events.addAll( + completeTextEvents( + textOutputIndex.get(), + messageId, + accumulatedText.toString(), + completedOutput, + hasTextDone)); + return events; + } + + private String structuredOutputText(Msg msg) { + if (msg == null || !msg.hasStructuredData()) { + throw new IllegalStateException( + "Structured output was requested but no structured data was returned"); + } + try { + return OBJECT_MAPPER.writeValueAsString(msg.getStructuredData(false)); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize structured output", e); + } + } + + private List completeTextEvents( + int outputIndex, + String messageId, + String text, + Map completedOutput, + AtomicBoolean hasTextDone) { + if (outputIndex < 0 || !hasTextDone.compareAndSet(false, true)) { + return List.of(); + } + + ResponsesOutputItem item = ResponsesOutputItem.message(messageId, text); + completedOutput.put(outputIndex, item); + + List events = new ArrayList<>(); + events.add( + ResponsesStreamEvent.textDone( + "response.output_text.done", outputIndex, 0, messageId, text)); + events.add( + ResponsesStreamEvent.contentPartEvent( + "response.content_part.done", + outputIndex, + 0, + messageId, + ResponsesContentPart.outputText(text))); + events.add( + ResponsesStreamEvent.outputItemEvent( + "response.output_item.done", outputIndex, item)); + return events; + } + + private Flux withStreamMetadata( + Flux events, String responseId) { + return events.index() + .map( + tuple -> { + ResponsesStreamEvent event = tuple.getT2(); + event.setSequenceNumber(tuple.getT1() + 1); + if (event.getResponse() == null) { + event.setResponseId(responseId); + } + return event; + }); + } + + private String text(Msg msg) { + if (msg == null || msg.getContent() == null) { + return ""; + } + StringBuilder builder = new StringBuilder(); + for (ContentBlock block : msg.getContent()) { + if (block instanceof TextBlock textBlock && textBlock.getText() != null) { + builder.append(textBlock.getText()); + } + } + return builder.toString(); + } + + private String argumentsJson(ToolUseBlock block) { + if (block.getContent() != null && !block.getContent().isBlank()) { + return compactJson(block.getContent()); + } + Map input = block.getInput(); + if (input == null || input.isEmpty()) { + return "{}"; + } + try { + return OBJECT_MAPPER.writeValueAsString(input); + } catch (JsonProcessingException e) { + return "{}"; + } + } + + private String compactJson(String json) { + try { + return OBJECT_MAPPER.writeValueAsString(OBJECT_MAPPER.readTree(json)); + } catch (Exception e) { + return json; + } + } + + private String functionCallId(String seed) { + return seed != null && seed.startsWith("fc_") ? seed : "fc_" + normalize(seed); + } + + private String messageId(String seed) { + if (seed != null && seed.startsWith("resp_")) { + return "msg_" + seed.substring("resp_".length()); + } + return seed != null && seed.startsWith("msg_") ? seed : "msg_" + normalize(seed); + } + + private String normalize(String seed) { + return seed == null || seed.isBlank() ? UUID.randomUUID().toString() : seed; + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/builder/ResponsesResponseBuilderTest.java b/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/builder/ResponsesResponseBuilderTest.java new file mode 100644 index 000000000..d75a59afb --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/builder/ResponsesResponseBuilderTest.java @@ -0,0 +1,140 @@ +/* + * 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.responses.builder; + +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 io.agentscope.core.message.MessageMetadataKeys; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatUsage; +import io.agentscope.core.responses.converter.ResponsesValidationException; +import io.agentscope.core.responses.model.ResponsesOutputItem; +import io.agentscope.core.responses.model.ResponsesRequest; +import io.agentscope.core.responses.model.ResponsesResponse; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ResponsesResponseBuilderTest { + + private ResponsesResponseBuilder builder; + + @BeforeEach + void setUp() { + builder = new ResponsesResponseBuilder(); + } + + @Test + void shouldBuildTextAndToolCallResponse() { + ResponsesRequest request = new ResponsesRequest(); + request.setModel("gpt-4.1-mini"); + request.setInstructions("Use tools when useful."); + + Msg reply = + Msg.builder() + .id("assistant-1") + .role(MsgRole.ASSISTANT) + .content( + TextBlock.builder().text("Let me check.").build(), + ToolUseBlock.builder() + .id("call_123") + .name("get_weather") + .input(Map.of("city", "Hangzhou")) + .build()) + .metadata( + Map.of( + MessageMetadataKeys.CHAT_USAGE, + ChatUsage.builder() + .inputTokens(10) + .outputTokens(5) + .time(0.3) + .build())) + .build(); + + ResponsesResponse response = builder.buildResponse(request, reply, "resp_123"); + + assertEquals("resp_123", response.getId()); + assertEquals("completed", response.getStatus()); + assertEquals("gpt-4.1-mini", response.getModel()); + assertEquals("Use tools when useful.", response.getInstructions()); + assertEquals("auto", response.getToolChoice()); + assertEquals("Let me check.", response.getOutputText()); + assertEquals(15, response.getUsage().getTotalTokens()); + assertEquals(2, response.getOutput().size()); + + ResponsesOutputItem message = response.getOutput().get(0); + assertEquals("message", message.getType()); + assertEquals("assistant", message.getRole()); + assertEquals("Let me check.", message.getContent().get(0).getText()); + + ResponsesOutputItem functionCall = response.getOutput().get(1); + assertEquals("function_call", functionCall.getType()); + assertEquals("call_123", functionCall.getCallId()); + assertEquals("get_weather", functionCall.getName()); + assertEquals("{\"city\":\"Hangzhou\"}", functionCall.getArguments()); + } + + @Test + void shouldBuildStructuredResponseFromMessageMetadata() { + ResponsesRequest request = new ResponsesRequest(); + Map structuredOutput = new LinkedHashMap<>(); + structuredOutput.put("answer", "42"); + structuredOutput.put("ok", true); + Msg reply = + Msg.builder() + .role(MsgRole.ASSISTANT) + .metadata(Map.of(MessageMetadataKeys.STRUCTURED_OUTPUT, structuredOutput)) + .build(); + + ResponsesResponse response = builder.buildStructuredResponse(request, reply, "resp_json"); + + assertEquals("completed", response.getStatus()); + assertEquals("{\"answer\":\"42\",\"ok\":true}", response.getOutputText()); + assertEquals("output_text", response.getOutput().get(0).getContent().get(0).getType()); + } + + @Test + void shouldBuildFailedStructuredResponseWhenMetadataIsMissing() { + ResponsesResponse response = + builder.buildStructuredResponse( + new ResponsesRequest(), + Msg.builder().role(MsgRole.ASSISTANT).build(), + "resp_bad"); + + assertEquals("failed", response.getStatus()); + assertNotNull(response.getError()); + assertEquals("invalid_response", response.getError().getCode()); + } + + @Test + void shouldBuildErrorResponseFromValidationException() { + ResponsesValidationException error = + ResponsesValidationException.unsupported("Unsupported", "input[0].type"); + + assertEquals( + "unsupported_parameter", builder.buildErrorResponse(error).getError().getCode()); + assertEquals("input[0].type", builder.buildErrorResponse(error).getError().getParam()); + assertNull( + builder.buildFailedResponse(new ResponsesRequest(), (Throwable) null, "resp") + .getUsage()); + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/converter/ResponsesGenerationOptionsConverterTest.java b/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/converter/ResponsesGenerationOptionsConverterTest.java new file mode 100644 index 000000000..87ede0aff --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/converter/ResponsesGenerationOptionsConverterTest.java @@ -0,0 +1,73 @@ +/* + * 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.responses.converter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolChoice; +import io.agentscope.core.responses.model.ResponsesRequest; +import org.junit.jupiter.api.Test; + +class ResponsesGenerationOptionsConverterTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final ResponsesGenerationOptionsConverter converter = + new ResponsesGenerationOptionsConverter(new ResponsesToolConverter()); + + @Test + void shouldReturnNullWhenNoOptionsArePresent() { + assertNull(converter.convert(new ResponsesRequest())); + } + + @Test + void shouldMapResponsesOptionsToGenerateOptions() throws Exception { + ResponsesRequest request = + OBJECT_MAPPER.readValue( + """ + { + "input": "Hello", + "model": "gpt-4.1-mini", + "stream": true, + "temperature": 0.2, + "top_p": 0.9, + "max_output_tokens": 128, + "reasoning": {"effort": "low"}, + "tool_choice": { + "type": "function", + "function": {"name": "get_weather"} + } + } + """, + ResponsesRequest.class); + + GenerateOptions options = converter.convert(request); + + assertEquals("gpt-4.1-mini", options.getModelName()); + assertEquals(true, options.getStream()); + assertEquals(0.2, options.getTemperature()); + assertEquals(0.9, options.getTopP()); + assertEquals(128, options.getMaxTokens()); + assertEquals("low", options.getReasoningEffort()); + ToolChoice.Specific toolChoice = + assertInstanceOf(ToolChoice.Specific.class, options.getToolChoice()); + assertEquals("get_weather", toolChoice.toolName()); + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/converter/ResponsesInputConverterTest.java b/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/converter/ResponsesInputConverterTest.java new file mode 100644 index 000000000..6d8de8296 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/converter/ResponsesInputConverterTest.java @@ -0,0 +1,236 @@ +/* + * 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.responses.converter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 io.agentscope.core.message.AudioBlock; +import io.agentscope.core.message.Base64Source; +import io.agentscope.core.message.ImageBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.message.URLSource; +import io.agentscope.core.responses.model.ResponsesRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ResponsesInputConverterTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private ResponsesInputConverter converter; + + @BeforeEach + void setUp() { + converter = new ResponsesInputConverter(); + } + + @Test + void shouldConvertStringInputAndInstructions() throws Exception { + ResponsesConversionResult result = + converter.convert( + request( + """ + { + "input": "Hello", + "instructions": "Be concise." + } + """)); + + assertEquals("text", result.textFormatType()); + assertNull(result.structuredOutputSchema()); + assertEquals(1, result.messages().size()); + assertEquals(MsgRole.USER, result.messages().get(0).getRole()); + assertEquals("Hello", result.messages().get(0).getTextContent()); + assertEquals("Be concise.", result.systemFragments().get(0)); + } + + @Test + void shouldConvertMessagesAndMultimodalContent() throws Exception { + ResponsesConversionResult result = + converter.convert( + request( + """ + { + "input": [ + {"role": "system", "content": "System rules"}, + {"role": "developer", "content": [{"type": "input_text", "text": "Developer rules"}]}, + { + "role": "user", + "content": [ + {"type": "input_text", "text": "Describe this"}, + {"type": "input_image", "image_url": "https://example.com/cat.png"}, + {"type": "input_image", "image_url": "data:image/png;base64,aGVsbG8="} + ] + } + ] + } + """)); + + assertEquals(1, result.messages().size()); + assertEquals(2, result.systemFragments().size()); + assertEquals("System rules", result.systemFragments().get(0)); + assertEquals("Developer rules", result.systemFragments().get(1)); + + Msg userMessage = result.messages().get(0); + assertEquals(MsgRole.USER, userMessage.getRole()); + assertEquals("Describe this", ((TextBlock) userMessage.getContent().get(0)).getText()); + + ImageBlock urlImage = assertInstanceOf(ImageBlock.class, userMessage.getContent().get(1)); + URLSource urlSource = assertInstanceOf(URLSource.class, urlImage.getSource()); + assertEquals("https://example.com/cat.png", urlSource.getUrl()); + + ImageBlock dataImage = assertInstanceOf(ImageBlock.class, userMessage.getContent().get(2)); + Base64Source base64Source = assertInstanceOf(Base64Source.class, dataImage.getSource()); + assertEquals("image/png", base64Source.getMediaType()); + assertEquals("aGVsbG8=", base64Source.getData()); + } + + @Test + void shouldConvertFunctionCallAndFunctionCallOutput() throws Exception { + ResponsesConversionResult result = + converter.convert( + request( + """ + { + "input": [ + { + "type": "function_call", + "call_id": "call_123", + "name": "get_weather", + "arguments": {"city": "Hangzhou"} + }, + { + "type": "function_call_output", + "call_id": "call_123", + "output": [{"type": "output_text", "text": "Sunny"}] + } + ] + } + """)); + + assertEquals(2, result.messages().size()); + + ToolUseBlock toolUse = + assertInstanceOf(ToolUseBlock.class, result.messages().get(0).getContent().get(0)); + assertEquals("call_123", toolUse.getId()); + assertEquals("get_weather", toolUse.getName()); + assertEquals("Hangzhou", toolUse.getInput().get("city")); + assertEquals("{\"city\":\"Hangzhou\"}", toolUse.getContent()); + + ToolResultBlock toolResult = + assertInstanceOf( + ToolResultBlock.class, result.messages().get(1).getContent().get(0)); + assertEquals("call_123", toolResult.getId()); + assertEquals("Sunny", ((TextBlock) toolResult.getOutput().get(0)).getText()); + } + + @Test + void shouldExposeJsonSchemaFormat() throws Exception { + ResponsesConversionResult result = + converter.convert( + request( + """ + { + "input": "Return JSON", + "text": { + "format": { + "type": "json_schema", + "schema": { + "type": "object", + "properties": { + "answer": {"type": "string"} + }, + "required": ["answer"] + } + } + } + } + """)); + + assertEquals("json_schema", result.textFormatType()); + assertTrue(result.structuredOutputSchema().isObject()); + assertEquals("object", result.structuredOutputSchema().get("type").asText()); + } + + @Test + void shouldAcceptStatefulAndAdditionalParameters() throws Exception { + ResponsesRequest request = + request( + """ + { + "input": "Hello", + "previous_response_id": "resp_old", + "conversation": "conv_123", + "background": true, + "store": true, + "include": ["reasoning.encrypted_content"] + } + """); + + ResponsesConversionResult result = converter.convert(request); + + assertEquals(1, result.messages().size()); + assertEquals("resp_old", request.getPreviousResponseId()); + assertEquals("conv_123", request.getConversation()); + assertTrue(request.getAdditionalFields().containsKey("include")); + } + + @Test + void shouldAcceptFileAudioAndOpaqueOfficialItems() throws Exception { + ResponsesConversionResult result = + converter.convert( + request( + """ + { + "input": [ + { + "role": "user", + "content": [ + {"type": "input_file", "file_id": "file_123"}, + {"type": "input_audio", "data": "aGVsbG8=", "format": "wav"} + ] + }, + { + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "Used cached context"}] + } + ] + } + """)); + + assertEquals(2, result.messages().size()); + Msg userMessage = result.messages().get(0); + assertTrue(((TextBlock) userMessage.getContent().get(0)).getText().contains("file_123")); + AudioBlock audio = assertInstanceOf(AudioBlock.class, userMessage.getContent().get(1)); + Base64Source audioSource = assertInstanceOf(Base64Source.class, audio.getSource()); + assertEquals("audio/wav", audioSource.getMediaType()); + assertEquals(MsgRole.ASSISTANT, result.messages().get(1).getRole()); + assertTrue(result.messages().get(1).getTextContent().contains("reasoning")); + } + + private ResponsesRequest request(String json) throws JsonProcessingException { + return OBJECT_MAPPER.readValue(json, ResponsesRequest.class); + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/converter/ResponsesToolConverterTest.java b/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/converter/ResponsesToolConverterTest.java new file mode 100644 index 000000000..3cc2e9114 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/converter/ResponsesToolConverterTest.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.responses.converter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.model.ToolChoice; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.responses.model.ResponsesTool; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ResponsesToolConverterTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private ResponsesToolConverter converter; + + @BeforeEach + void setUp() { + converter = new ResponsesToolConverter(); + } + + @Test + void shouldConvertFlatFunctionTool() throws Exception { + ResponsesTool tool = new ResponsesTool(); + tool.setType("function"); + tool.setName("get_weather"); + tool.setDescription("Get weather for a city"); + tool.setStrict(true); + tool.setParameters( + OBJECT_MAPPER.readTree( + """ + { + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + } + """)); + + List schemas = converter.convertToToolSchemas(List.of(tool)); + + assertEquals(1, schemas.size()); + assertEquals("get_weather", schemas.get(0).getName()); + assertEquals("Get weather for a city", schemas.get(0).getDescription()); + assertTrue(schemas.get(0).getStrict()); + assertEquals("object", schemas.get(0).getParameters().get("type")); + } + + @Test + void shouldIgnoreHostedToolsWhenNoAgentScopeBackendIsRegistered() { + ResponsesTool tool = new ResponsesTool(); + tool.setType("web_search_preview"); + + List schemas = converter.convertToToolSchemas(List.of(tool)); + + assertTrue(schemas.isEmpty()); + } + + @Test + void shouldConvertSupportedToolChoiceValues() throws Exception { + assertInstanceOf(ToolChoice.Auto.class, converter.convertToolChoice(json("\"auto\""))); + assertInstanceOf(ToolChoice.None.class, converter.convertToolChoice(json("\"none\""))); + assertInstanceOf( + ToolChoice.Required.class, converter.convertToolChoice(json("\"required\""))); + + ToolChoice choice = + converter.convertToolChoice( + json( + """ + { + "type": "function", + "name": "get_weather" + } + """)); + + ToolChoice.Specific specific = assertInstanceOf(ToolChoice.Specific.class, choice); + assertEquals("get_weather", specific.toolName()); + } + + @Test + void shouldAcceptAllowedToolsAndIgnoreHostedToolChoices() throws Exception { + ToolChoice.Specific functionChoice = + assertInstanceOf( + ToolChoice.Specific.class, + converter.convertToolChoice( + json( + """ + { + "type": "function", + "name": "get_weather", + "allowed_tools": ["get_weather"] + } + """))); + assertEquals("get_weather", functionChoice.toolName()); + + assertNull( + converter.convertToolChoice( + json( + """ + { + "type": "web_search_preview" + } + """))); + } + + private JsonNode json(String json) throws Exception { + return OBJECT_MAPPER.readTree(json); + } +} diff --git a/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/streaming/ResponsesStreamingAdapterTest.java b/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/streaming/ResponsesStreamingAdapterTest.java new file mode 100644 index 000000000..fa5e8c520 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-responses-web/src/test/java/io/agentscope/core/responses/streaming/ResponsesStreamingAdapterTest.java @@ -0,0 +1,339 @@ +/* + * 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.responses.streaming; + +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.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.Event; +import io.agentscope.core.agent.EventType; +import io.agentscope.core.agent.StreamOptions; +import io.agentscope.core.message.MessageMetadataKeys; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.responses.model.ResponsesRequest; +import io.agentscope.core.responses.model.ResponsesStreamEvent; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +class ResponsesStreamingAdapterTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private ResponsesStreamingAdapter adapter; + + @BeforeEach + void setUp() { + adapter = new ResponsesStreamingAdapter(); + } + + @Test + void shouldConvertIncrementalTextEventsToResponsesStreamEvents() { + ReActAgent agent = mock(ReActAgent.class); + when(agent.stream(anyList(), any(StreamOptions.class))) + .thenReturn( + Flux.just( + new Event(EventType.REASONING, assistantText("Hel"), false), + new Event(EventType.REASONING, assistantText("lo"), false), + new Event(EventType.REASONING, assistantText("Hello"), true))); + + List events = + adapter.stream(agent, List.of(userText("Hello")), request(), "resp_123") + .collectList() + .block(); + + assertNotNull(events); + assertEquals("response.created", events.get(0).getType()); + assertEquals("response.in_progress", events.get(1).getType()); + assertEquals("response.output_item.added", events.get(2).getType()); + assertEquals("response.content_part.added", events.get(3).getType()); + assertEquals("response.output_text.delta", events.get(4).getType()); + assertEquals("Hel", events.get(4).getDelta()); + assertEquals(5, events.get(4).getSequenceNumber()); + assertEquals("resp_123", events.get(4).getResponseId()); + assertEquals("response.output_text.delta", events.get(5).getType()); + assertEquals("lo", events.get(5).getDelta()); + assertTrue( + events.stream() + .anyMatch(event -> "response.output_text.done".equals(event.getType()))); + assertEquals("response.completed", events.get(events.size() - 1).getType()); + assertEquals("Hello", events.get(events.size() - 1).getResponse().getOutputText()); + assertEquals(1, events.get(events.size() - 1).getResponse().getOutput().size()); + assertEquals( + "Hello", + events.get(events.size() - 1) + .getResponse() + .getOutput() + .get(0) + .getContent() + .get(0) + .getText()); + } + + @Test + void shouldConvertToolUseEventsToFunctionCallOutputItems() { + ReActAgent agent = mock(ReActAgent.class); + Msg toolCall = + Msg.builder() + .role(MsgRole.ASSISTANT) + .content( + ToolUseBlock.builder() + .id("call_123") + .name("get_weather") + .input(Map.of("city", "Hangzhou")) + .build()) + .build(); + when(agent.stream(anyList(), any(StreamOptions.class))) + .thenReturn(Flux.just(new Event(EventType.REASONING, toolCall, true))); + + List events = + adapter.stream(agent, List.of(userText("Weather?")), request(), "resp_456") + .collectList() + .block(); + + assertNotNull(events); + ResponsesStreamEvent added = + events.stream() + .filter(event -> "response.output_item.added".equals(event.getType())) + .findFirst() + .orElseThrow(); + assertEquals("function_call", added.getItem().getType()); + assertEquals("call_123", added.getItem().getCallId()); + assertEquals("get_weather", added.getItem().getName()); + + ResponsesStreamEvent argumentsDone = + events.stream() + .filter( + event -> + "response.function_call_arguments.done" + .equals(event.getType())) + .findFirst() + .orElseThrow(); + assertEquals("{\"city\":\"Hangzhou\"}", argumentsDone.getArguments()); + assertEquals("call_123", argumentsDone.getCallId()); + assertEquals("get_weather", argumentsDone.getName()); + + ResponsesStreamEvent argumentsDelta = + events.stream() + .filter( + event -> + "response.function_call_arguments.delta" + .equals(event.getType())) + .findFirst() + .orElseThrow(); + assertEquals("{\"city\":\"Hangzhou\"}", argumentsDelta.getDelta()); + assertEquals("resp_456", argumentsDelta.getResponseId()); + } + + @Test + void shouldKeepDistinctOutputIndexesWhenFunctionCallPrecedesText() { + ReActAgent agent = mock(ReActAgent.class); + Msg toolCall = + Msg.builder() + .role(MsgRole.ASSISTANT) + .content( + ToolUseBlock.builder() + .id("call_first") + .name("lookup") + .input(Map.of("query", "AgentScope")) + .build()) + .build(); + when(agent.stream(anyList(), any(StreamOptions.class))) + .thenReturn( + Flux.just( + new Event(EventType.REASONING, toolCall, false), + new Event(EventType.REASONING, assistantText("Done"), true))); + + List events = + adapter.stream(agent, List.of(userText("Lookup")), request(), "resp_order") + .collectList() + .block(); + + assertNotNull(events); + ResponsesStreamEvent toolAdded = + events.stream() + .filter( + event -> + "response.output_item.added".equals(event.getType()) + && "function_call" + .equals(event.getItem().getType())) + .findFirst() + .orElseThrow(); + ResponsesStreamEvent textAdded = + events.stream() + .filter( + event -> + "response.output_item.added".equals(event.getType()) + && "message".equals(event.getItem().getType())) + .findFirst() + .orElseThrow(); + + assertEquals(0, toolAdded.getOutputIndex()); + assertEquals(1, textAdded.getOutputIndex()); + assertEquals(2, events.get(events.size() - 1).getResponse().getOutput().size()); + } + + @Test + void shouldSerializeOutputItemWithOfficialResponsesFieldName() throws Exception { + ResponsesStreamEvent event = + ResponsesStreamEvent.outputItemEvent( + "response.output_item.added", + 0, + io.agentscope.core.responses.model.ResponsesOutputItem.message( + "msg_1", "")); + + String json = OBJECT_MAPPER.writeValueAsString(event); + + assertTrue(json.contains("\"item\"")); + assertFalse(json.contains("\"output_item\"")); + assertFalse(json.contains("\"outputItem\"")); + } + + @Test + void shouldStreamStructuredOutputAsResponsesTextEvents() throws Exception { + ReActAgent agent = mock(ReActAgent.class); + Map structuredOutput = new LinkedHashMap<>(); + structuredOutput.put("answer", "42"); + structuredOutput.put("ok", true); + Msg reply = structuredAssistant(structuredOutput); + JsonNode schema = + OBJECT_MAPPER.readTree( + """ + { + "type": "object", + "properties": { + "answer": { "type": "string" }, + "ok": { "type": "boolean" } + }, + "required": ["answer", "ok"] + } + """); + when(agent.stream(anyList(), any(StreamOptions.class), any(JsonNode.class))) + .thenReturn(Flux.just(new Event(EventType.AGENT_RESULT, reply, true))); + + List events = + adapter.stream( + agent, + List.of(userText("Return JSON")), + schema, + request(), + "resp_json") + .collectList() + .block(); + + assertNotNull(events); + assertEquals("response.created", events.get(0).getType()); + assertEquals("response.in_progress", events.get(1).getType()); + ResponsesStreamEvent delta = + events.stream() + .filter(event -> "response.output_text.delta".equals(event.getType())) + .findFirst() + .orElseThrow(); + assertEquals("{\"answer\":\"42\",\"ok\":true}", delta.getDelta()); + assertEquals("resp_json", delta.getResponseId()); + assertTrue( + events.stream() + .anyMatch(event -> "response.output_text.done".equals(event.getType()))); + ResponsesStreamEvent completed = events.get(events.size() - 1); + assertEquals("response.completed", completed.getType()); + assertEquals("{\"answer\":\"42\",\"ok\":true}", completed.getResponse().getOutputText()); + assertEquals(1, completed.getResponse().getOutput().size()); + verify(agent).stream(anyList(), any(StreamOptions.class), any(JsonNode.class)); + } + + @Test + void shouldFailStructuredStreamWhenAgentReturnsNoStructuredData() throws Exception { + ReActAgent agent = mock(ReActAgent.class); + when(agent.stream(anyList(), any(StreamOptions.class), any(JsonNode.class))) + .thenReturn( + Flux.just( + new Event( + EventType.AGENT_RESULT, + assistantText("plain text"), + true))); + + List events = + adapter.stream( + agent, + List.of(userText("Return JSON")), + OBJECT_MAPPER.readTree("{\"type\":\"object\"}"), + request(), + "resp_bad_json") + .collectList() + .block(); + + assertNotNull(events); + ResponsesStreamEvent failed = events.get(events.size() - 1); + assertEquals("response.failed", failed.getType()); + assertEquals("failed", failed.getResponse().getStatus()); + assertEquals("runtime_error", failed.getResponse().getError().getCode()); + } + + @Test + void shouldCreateFailedEvent() { + ResponsesStreamEvent event = + adapter.createFailedEvent( + new IllegalStateException("boom"), request(), "resp_fail"); + + assertEquals("response.failed", event.getType()); + assertEquals("failed", event.getResponse().getStatus()); + assertEquals("runtime_error", event.getResponse().getError().getCode()); + } + + private ResponsesRequest request() { + ResponsesRequest request = new ResponsesRequest(); + request.setModel("gpt-test"); + return request; + } + + private Msg userText(String text) { + return Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + private Msg assistantText(String text) { + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + private Msg structuredAssistant(Map data) { + Map structured = new LinkedHashMap<>(data); + return Msg.builder() + .role(MsgRole.ASSISTANT) + .metadata(Map.of(MessageMetadataKeys.STRUCTURED_OUTPUT, structured)) + .build(); + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/pom.xml b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/pom.xml new file mode 100644 index 000000000..ecce72b41 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/pom.xml @@ -0,0 +1,78 @@ + + + + + 4.0.0 + + + io.agentscope + agentscope-spring-boot-starters + ${revision} + + + agentscope-responses-web-starter + AgentScope Java - Responses Web Starter + Spring Boot starter exposing an OpenAI Responses style HTTP API for AgentScope agents + + + false + + + + + io.agentscope + agentscope-core + true + provided + + + io.agentscope + agentscope-extensions-responses-web + ${revision} + + + io.agentscope + agentscope-spring-boot-starter + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/config/ResponsesProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/config/ResponsesProperties.java new file mode 100644 index 000000000..4f00908f3 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/config/ResponsesProperties.java @@ -0,0 +1,45 @@ +/* + * 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.spring.boot.responses.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Configuration properties for the Responses Web Starter. */ +@ConfigurationProperties(prefix = "agentscope.responses") +public class ResponsesProperties { + + /** Whether the Responses HTTP API is enabled. */ + private boolean enabled = true; + + /** Base path for the Responses endpoint. */ + private String basePath = "/v1/responses"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getBasePath() { + return basePath; + } + + public void setBasePath(String basePath) { + this.basePath = basePath; + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/config/ResponsesWebAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/config/ResponsesWebAutoConfiguration.java new file mode 100644 index 000000000..82c62146b --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/config/ResponsesWebAutoConfiguration.java @@ -0,0 +1,133 @@ +/* + * 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.spring.boot.responses.config; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.responses.builder.ResponsesResponseBuilder; +import io.agentscope.core.responses.converter.ResponsesGenerationOptionsConverter; +import io.agentscope.core.responses.converter.ResponsesInputConverter; +import io.agentscope.core.responses.converter.ResponsesToolConverter; +import io.agentscope.core.responses.streaming.ResponsesStreamingAdapter; +import io.agentscope.spring.boot.responses.service.ResponsesStateService; +import io.agentscope.spring.boot.responses.service.ResponsesStreamingService; +import io.agentscope.spring.boot.responses.web.ConversationsController; +import io.agentscope.spring.boot.responses.web.ResponsesController; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * Auto-configuration for exposing Responses and Conversations API endpoints. + * + *

The starter contributes only the web-facing Responses API beans. It relies on + * {@code agentscope-spring-boot-starter} to provide prototype-scoped {@link ReActAgent} instances, + * so applications can enable this starter without writing their own controller. + */ +@AutoConfiguration +@EnableConfigurationProperties(ResponsesProperties.class) +@ConditionalOnProperty( + prefix = "agentscope.responses", + name = "enabled", + havingValue = "true", + matchIfMissing = true) +@ConditionalOnClass(ReActAgent.class) +public class ResponsesWebAutoConfiguration { + + /** Create the request converter that maps Responses DTOs to AgentScope messages. */ + @Bean + @ConditionalOnMissingBean + public ResponsesInputConverter responsesInputConverter() { + return new ResponsesInputConverter(); + } + + /** Create the converter that maps Responses function tools to AgentScope tool schemas. */ + @Bean + @ConditionalOnMissingBean + public ResponsesToolConverter responsesToolConverter() { + return new ResponsesToolConverter(); + } + + /** Create the converter for request-level generation options. */ + @Bean + @ConditionalOnMissingBean + public ResponsesGenerationOptionsConverter responsesGenerationOptionsConverter( + ResponsesToolConverter toolConverter) { + return new ResponsesGenerationOptionsConverter(toolConverter); + } + + /** Create the builder for non-streaming and terminal streaming Responses objects. */ + @Bean + @ConditionalOnMissingBean + public ResponsesResponseBuilder responsesResponseBuilder() { + return new ResponsesResponseBuilder(); + } + + /** Create the framework-agnostic adapter from AgentScope stream events to Responses events. */ + @Bean + @ConditionalOnMissingBean + public ResponsesStreamingAdapter responsesStreamingAdapter( + ResponsesResponseBuilder responseBuilder) { + return new ResponsesStreamingAdapter(responseBuilder); + } + + /** Create the Spring SSE service for Responses streaming. */ + @Bean + @ConditionalOnMissingBean + public ResponsesStreamingService responsesStreamingService( + ResponsesStreamingAdapter streamingAdapter) { + return new ResponsesStreamingService(streamingAdapter); + } + + /** Create the state service for stored responses, background requests, and conversations. */ + @Bean + @ConditionalOnMissingBean + public ResponsesStateService responsesStateService() { + return new ResponsesStateService(); + } + + /** Create the HTTP controller that exposes the configured Responses endpoint. */ + @Bean + @ConditionalOnMissingBean + public ResponsesController responsesController( + ObjectProvider agentProvider, + ResponsesInputConverter inputConverter, + ResponsesToolConverter toolConverter, + ResponsesGenerationOptionsConverter generationOptionsConverter, + ResponsesResponseBuilder responseBuilder, + ResponsesStreamingService streamingService, + ResponsesStateService stateService) { + return new ResponsesController( + agentProvider, + inputConverter, + toolConverter, + generationOptionsConverter, + responseBuilder, + streamingService, + stateService); + } + + /** Create the HTTP controller that exposes the configured Conversations endpoint. */ + @Bean + @ConditionalOnMissingBean + public ConversationsController conversationsController( + ResponsesStateService stateService, ResponsesResponseBuilder responseBuilder) { + return new ConversationsController(stateService, responseBuilder); + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/service/ResponsesStateService.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/service/ResponsesStateService.java new file mode 100644 index 000000000..56a6ada67 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/service/ResponsesStateService.java @@ -0,0 +1,565 @@ +/* + * 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.spring.boot.responses.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.responses.converter.ResponsesValidationException; +import io.agentscope.core.responses.model.ResponsesConversation; +import io.agentscope.core.responses.model.ResponsesConversationRequest; +import io.agentscope.core.responses.model.ResponsesDeletionStatus; +import io.agentscope.core.responses.model.ResponsesList; +import io.agentscope.core.responses.model.ResponsesOutputItem; +import io.agentscope.core.responses.model.ResponsesRequest; +import io.agentscope.core.responses.model.ResponsesResponse; +import io.agentscope.core.responses.model.ResponsesTokenCount; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import reactor.core.Disposable; + +/** + * In-memory state service for Responses and Conversations resources. + * + *

This service provides OpenAI-compatible stateful API behavior for the starter without forcing + * an external database. Applications can replace this bean with a durable implementation when they + * need persistence across process restarts. + */ +public class ResponsesStateService { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final ConcurrentMap responses = new ConcurrentHashMap<>(); + private final ConcurrentMap conversations = + new ConcurrentHashMap<>(); + + /** + * Prepare a request by expanding previous response and conversation context into input. + * + *

The public Responses request remains stateless over HTTP, but {@code + * previous_response_id} and {@code conversation} require the model call to see earlier input and + * output items. This method builds that effective input while also returning the current input + * items so they can be stored separately. + * + * @param request Original Responses request + * @return Prepared request and state metadata + */ + public PreparedRequest prepare(ResponsesRequest request) { + List currentInputItems = inputItems(request.getInput()); + List effectiveInput = new ArrayList<>(); + + if (request.getPreviousResponseId() != null && !request.getPreviousResponseId().isBlank()) { + StoredResponse previous = responseRecord(request.getPreviousResponseId()); + // OpenAI's previous_response_id semantics replay prior input and output as context for + // the next call. + effectiveInput.addAll(previous.inputItems()); + effectiveInput.addAll(outputItems(previous.response())); + } + + String conversationId = resolveConversationId(request.getConversation()); + if (conversationId != null) { + // Conversation items are appended before current input so the latest user message stays + // last in the model context. + effectiveInput.addAll(conversationRecord(conversationId).items()); + } + + effectiveInput.addAll(currentInputItems); + + ResponsesRequest effectiveRequest = copyRequest(request); + effectiveRequest.setInput(effectiveInput); + if (conversationId != null) { + effectiveRequest.setConversation(conversationId); + } + return new PreparedRequest(effectiveRequest, currentInputItems, conversationId); + } + + /** + * Store or update a completed, failed, queued, or canceled response. + * + *

Responses are stored when {@code store=true} or when the response belongs to a background + * request. Conversation input/output items are appended at the same time. + * + * @param response Response to store + * @param prepared Prepared request metadata + * @return The same response object + */ + public ResponsesResponse save(ResponsesResponse response, PreparedRequest prepared) { + if (response == null) { + return null; + } + if (prepared != null && prepared.conversationId() != null) { + response.setConversation(prepared.conversationId()); + appendConversationItems( + prepared.conversationId(), prepared.currentInputItems(), outputItems(response)); + } + if (Boolean.TRUE.equals(response.getStore()) + || Boolean.TRUE.equals(response.getBackground())) { + responses.put( + response.getId(), + new StoredResponse( + response, + prepared != null + ? withItemIds(prepared.currentInputItems()) + : List.of(), + prepared != null ? prepared.conversationId() : null, + null)); + } + return response; + } + + /** + * Store a queued background response and remember its running task for cancellation. + * + * @param response Queued response placeholder + * @param prepared Prepared request metadata + * @param task Running background subscription, if already available + */ + public void saveBackground( + ResponsesResponse response, PreparedRequest prepared, Disposable task) { + responses.put( + response.getId(), + new StoredResponse( + response, + prepared != null ? withItemIds(prepared.currentInputItems()) : List.of(), + prepared != null ? prepared.conversationId() : null, + task)); + } + + /** + * Attach the background subscription after the queued response has already been stored. + * + * @param responseId Response ID + * @param task Running background subscription + */ + public void attachBackgroundTask(String responseId, Disposable task) { + responses.computeIfPresent( + responseId, + (id, stored) -> + new StoredResponse( + stored.response(), + stored.inputItems(), + stored.conversationId(), + task)); + } + + /** + * Retrieve a stored response. + * + * @param responseId Response ID + * @return Stored response + */ + public ResponsesResponse retrieveResponse(String responseId) { + return responseRecord(responseId).response(); + } + + /** + * Delete a stored response and cancel its background task if it is still running. + * + * @param responseId Response ID + * @return Deletion status + */ + public ResponsesDeletionStatus deleteResponse(String responseId) { + StoredResponse removed = responses.remove(responseId); + if (removed == null) { + throw notFound("Response not found: " + responseId, "response_id"); + } + if (removed.backgroundTask() != null && !removed.backgroundTask().isDisposed()) { + removed.backgroundTask().dispose(); + } + return new ResponsesDeletionStatus(responseId, "response.deleted", true); + } + + /** + * Cancel a stored response. + * + *

For queued/running background responses this disposes the subscription. For already + * completed in-memory responses it simply marks the resource as cancelled, matching the + * resource-oriented API shape. + * + * @param responseId Response ID + * @return Canceled response + */ + public ResponsesResponse cancelResponse(String responseId) { + StoredResponse stored = responseRecord(responseId); + if (stored.backgroundTask() != null && !stored.backgroundTask().isDisposed()) { + stored.backgroundTask().dispose(); + } + stored.response().setStatus("cancelled"); + return stored.response(); + } + + /** + * List response input items with cursor pagination. + * + * @param responseId Response ID + * @param after Cursor item ID + * @param limit Maximum item count + * @param order Sort order, either {@code asc} or {@code desc} + * @return List wrapper containing input items + */ + public ResponsesList responseInputItems( + String responseId, String after, Integer limit, String order) { + return page(responseRecord(responseId).inputItems(), after, limit, order); + } + + /** + * Count input tokens approximately. + * + *

The generic starter does not have provider-specific tokenizers, so this endpoint returns a + * stable approximation based on serialized input length. + * + * @param request Responses request + * @return Approximate token count + */ + public ResponsesTokenCount countInputTokens(ResponsesRequest request) { + try { + String json = + OBJECT_MAPPER.writeValueAsString(request != null ? request.getInput() : null); + return new ResponsesTokenCount(Math.max(1, (int) Math.ceil(json.length() / 4.0))); + } catch (JsonProcessingException e) { + return new ResponsesTokenCount(0); + } + } + + /** + * Create a conversation with optional metadata and initial items. + * + * @param request Conversation creation request + * @return Created conversation + */ + public ResponsesConversation createConversation(ResponsesConversationRequest request) { + ResponsesConversation conversation = new ResponsesConversation(); + conversation.setId("conv_" + UUID.randomUUID()); + conversation.setCreatedAt(Instant.now().getEpochSecond()); + if (request != null) { + conversation.setMetadata(request.getMetadata()); + } + List items = + request != null && request.getItems() != null + ? withItemIds(request.getItems()) + : new ArrayList<>(); + conversations.put(conversation.getId(), new StoredConversation(conversation, items)); + return conversation; + } + + /** + * Retrieve a conversation. + * + * @param conversationId Conversation ID + * @return Stored conversation + */ + public ResponsesConversation retrieveConversation(String conversationId) { + return conversationRecord(conversationId).conversation(); + } + + /** + * Update conversation metadata. + * + * @param conversationId Conversation ID + * @param request Metadata update request + * @return Updated conversation + */ + public ResponsesConversation updateConversation( + String conversationId, ResponsesConversationRequest request) { + StoredConversation stored = conversationRecord(conversationId); + if (request != null && request.getMetadata() != null) { + stored.conversation().setMetadata(request.getMetadata()); + } + return stored.conversation(); + } + + /** + * Delete a conversation. + * + * @param conversationId Conversation ID + * @return Deletion status + */ + public ResponsesDeletionStatus deleteConversation(String conversationId) { + StoredConversation removed = conversations.remove(conversationId); + if (removed == null) { + throw notFound("Conversation not found: " + conversationId, "conversation_id"); + } + return new ResponsesDeletionStatus(conversationId, "conversation.deleted", true); + } + + /** + * List conversation items with cursor pagination. + * + * @param conversationId Conversation ID + * @param after Cursor item ID + * @param limit Maximum item count + * @param order Sort order, either {@code asc} or {@code desc} + * @return List wrapper containing conversation items + */ + public ResponsesList listConversationItems( + String conversationId, String after, Integer limit, String order) { + return page(conversationRecord(conversationId).items(), after, limit, order); + } + + /** + * Append items to a conversation. + * + * @param conversationId Conversation ID + * @param items Items to append + * @return List wrapper containing the created items + */ + public ResponsesList createConversationItems( + String conversationId, List items) { + StoredConversation stored = conversationRecord(conversationId); + List created = withItemIds(items != null ? items : List.of()); + stored.items().addAll(created); + return new ResponsesList<>(created); + } + + /** + * Retrieve one conversation item. + * + * @param conversationId Conversation ID + * @param itemId Item ID + * @return Stored item + */ + public Object retrieveConversationItem(String conversationId, String itemId) { + return conversationRecord(conversationId).items().stream() + .filter(item -> itemId.equals(itemId(item))) + .findFirst() + .orElseThrow(() -> notFound("Conversation item not found: " + itemId, "item_id")); + } + + /** + * Delete one conversation item. + * + * @param conversationId Conversation ID + * @param itemId Item ID + * @return Deletion status + */ + public ResponsesDeletionStatus deleteConversationItem(String conversationId, String itemId) { + StoredConversation stored = conversationRecord(conversationId); + boolean removed = stored.items().removeIf(item -> itemId.equals(itemId(item))); + if (!removed) { + throw notFound("Conversation item not found: " + itemId, "item_id"); + } + return new ResponsesDeletionStatus(itemId, "conversation.item.deleted", true); + } + + private void appendConversationItems( + String conversationId, List inputItems, List outputItems) { + StoredConversation stored = conversationRecord(conversationId); + stored.items().addAll(withItemIds(inputItems)); + stored.items().addAll(withItemIds(outputItems)); + } + + private ResponsesList page( + List source, String after, Integer limit, String order) { + if (limit != null && limit < 0) { + throw ResponsesValidationException.invalid("limit must be non-negative", "limit"); + } + if (order != null && !order.isBlank() && !"asc".equals(order) && !"desc".equals(order)) { + throw ResponsesValidationException.invalid("order must be asc or desc", "order"); + } + + List ordered = new ArrayList<>(source != null ? source : List.of()); + if ("desc".equals(order)) { + Collections.reverse(ordered); + } + + // The API uses item IDs as cursors. If the cursor is absent, start from the beginning; + // if it is not found, the behavior is intentionally the same as an empty cursor. + int start = 0; + if (after != null && !after.isBlank()) { + for (int i = 0; i < ordered.size(); i++) { + if (after.equals(itemId(ordered.get(i)))) { + start = i + 1; + break; + } + } + } + + int max = limit != null ? limit : ordered.size(); + int end = Math.min(ordered.size(), start + max); + return new ResponsesList<>( + new ArrayList<>(ordered.subList(start, end)), end < ordered.size()); + } + + private String resolveConversationId(Object conversation) { + if (conversation == null || OBJECT_MAPPER.valueToTree(conversation).isNull()) { + return null; + } + JsonNode node = OBJECT_MAPPER.valueToTree(conversation); + if (node.isTextual()) { + String value = node.asText(); + if ("none".equals(value)) { + return null; + } + if ("auto".equals(value)) { + // "auto" asks the service to allocate a new conversation for this request. + return createConversation(null).getId(); + } + conversationRecord(value); + return value; + } + if (node.isObject()) { + String id = node.hasNonNull("id") ? node.get("id").asText() : null; + if (id == null || id.isBlank() || "auto".equals(id)) { + // Object-shaped conversation payloads may omit an ID. Treat that as auto-create. + return createConversation(null).getId(); + } + conversationRecord(id); + return id; + } + throw ResponsesValidationException.invalid( + "conversation must be a string or object", "conversation"); + } + + private StoredResponse responseRecord(String responseId) { + StoredResponse stored = responses.get(responseId); + if (stored == null) { + throw notFound("Response not found: " + responseId, "response_id"); + } + return stored; + } + + private StoredConversation conversationRecord(String conversationId) { + StoredConversation stored = conversations.get(conversationId); + if (stored == null) { + throw notFound("Conversation not found: " + conversationId, "conversation_id"); + } + return stored; + } + + private ResponsesValidationException notFound(String message, String param) { + return new ResponsesValidationException(message, param, "not_found"); + } + + private ResponsesRequest copyRequest(ResponsesRequest request) { + ResponsesRequest copy = new ResponsesRequest(); + copy.setModel(request.getModel()); + copy.setInput(request.getInput()); + copy.setInstructions(request.getInstructions()); + copy.setStream(request.getStream()); + copy.setTools(request.getTools()); + copy.setToolChoice(request.getToolChoice()); + copy.setTemperature(request.getTemperature()); + copy.setTopP(request.getTopP()); + copy.setMaxOutputTokens(request.getMaxOutputTokens()); + copy.setReasoning(request.getReasoning()); + copy.setText(request.getText()); + copy.setPreviousResponseId(request.getPreviousResponseId()); + copy.setConversation(request.getConversation()); + copy.setBackground(request.getBackground()); + copy.setStore(request.getStore()); + copy.setMetadata(request.getMetadata()); + request.getAdditionalFields().forEach(copy::putAdditionalField); + return copy; + } + + private List inputItems(Object input) { + JsonNode node = OBJECT_MAPPER.valueToTree(input); + if (node == null || node.isNull()) { + return List.of(); + } + if (node.isTextual()) { + // String input is the common shorthand; store it back as an official message item so + // input_items and previous_response_id use the same shape as object input. + return List.of(userMessageItem(node.asText())); + } + if (node.isArray()) { + List result = new ArrayList<>(); + for (JsonNode child : node) { + result.add(toObject(child)); + } + return result; + } + return List.of(toObject(node)); + } + + private Map userMessageItem(String text) { + Map part = new LinkedHashMap<>(); + part.put("type", "input_text"); + part.put("text", text); + Map item = new LinkedHashMap<>(); + item.put("type", "message"); + item.put("role", "user"); + item.put("content", List.of(part)); + return item; + } + + private List outputItems(ResponsesResponse response) { + if (response == null || response.getOutput() == null) { + return List.of(); + } + List result = new ArrayList<>(); + for (ResponsesOutputItem item : response.getOutput()) { + result.add(toObject(item)); + } + return result; + } + + private List withItemIds(List items) { + List result = new ArrayList<>(); + for (Object item : items) { + Map map = toMap(item); + // Client-provided IDs are preserved, otherwise allocate an OpenAI-style prefix based on + // the item type. + map.putIfAbsent("id", itemIdPrefix(map) + UUID.randomUUID()); + result.add(map); + } + return result; + } + + private String itemIdPrefix(Map item) { + Object type = item.get("type"); + if ("function_call".equals(type)) { + return "fc_"; + } + if ("function_call_output".equals(type)) { + return "fco_"; + } + return "msg_"; + } + + private String itemId(Object item) { + Map map = toMap(item); + Object id = map.get("id"); + return id instanceof String text ? text : null; + } + + private Object toObject(Object value) { + return OBJECT_MAPPER.convertValue(value, Object.class); + } + + private Map toMap(Object value) { + return OBJECT_MAPPER.convertValue(value, new TypeReference<>() {}); + } + + public record PreparedRequest( + ResponsesRequest request, List currentInputItems, String conversationId) {} + + private record StoredResponse( + ResponsesResponse response, + List inputItems, + String conversationId, + Disposable backgroundTask) {} + + private record StoredConversation(ResponsesConversation conversation, List items) {} +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/service/ResponsesStreamingService.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/service/ResponsesStreamingService.java new file mode 100644 index 000000000..87f7816a4 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/service/ResponsesStreamingService.java @@ -0,0 +1,166 @@ +/* + * 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.spring.boot.responses.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.ReActAgent; +import io.agentscope.core.message.Msg; +import io.agentscope.core.responses.model.ResponsesRequest; +import io.agentscope.core.responses.model.ResponsesStreamEvent; +import io.agentscope.core.responses.streaming.ResponsesStreamingAdapter; +import java.util.List; +import java.util.function.Consumer; +import org.springframework.http.codec.ServerSentEvent; +import reactor.core.publisher.Flux; + +/** + * Spring-specific service for streaming Responses API events. + * + *

This service is a thin adapter layer that: + * + *

    + *
  • Delegates framework-agnostic streaming logic to {@link ResponsesStreamingAdapter} + *
  • Converts {@link ResponsesStreamEvent} objects to Spring {@link ServerSentEvent} objects + *
  • Handles JSON serialization for the SSE data field + *
+ * + *

Architecture: + * + *

+ * ResponsesStreamingAdapter (framework-agnostic, in extension-core)
+ *           -> Flux<ResponsesStreamEvent>
+ * ResponsesStreamingService (Spring-specific, in starter)
+ *           -> Flux<ServerSentEvent<String>>
+ * HTTP Response (Responses-style SSE stream)
+ * 
+ */ +public class ResponsesStreamingService { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final ResponsesStreamingAdapter streamingAdapter; + + /** + * Constructs a new {@code ResponsesStreamingService}. + * + * @param streamingAdapter The framework-agnostic Responses streaming adapter + */ + public ResponsesStreamingService(ResponsesStreamingAdapter streamingAdapter) { + this.streamingAdapter = streamingAdapter; + } + + /** + * Stream agent events as Responses API Server-Sent Events. + * + *

Each SSE data field contains one JSON-serialized {@link ResponsesStreamEvent}. Responses + * streams do not append a Chat Completions-style {@code [DONE]} sentinel; completion is carried + * by the {@code response.completed} event. + * + * @param agent The agent to stream from + * @param messages The messages to send to the agent + * @param request The original Responses API request + * @param responseId The response ID used across the stream + * @return A {@link Flux} of Spring SSE events + */ + public Flux> streamAsSse( + ReActAgent agent, List messages, ResponsesRequest request, String responseId) { + return streamingAdapter.stream(agent, messages, request, responseId).map(this::toSse); + } + + /** + * Stream agent events as Responses API Server-Sent Events with JSON Schema structured output. + * + *

The core agent uses its structured-output path, and the streaming adapter converts the + * final structured payload into standard Responses text delta/done/completed events. + * + * @param agent The agent to stream from + * @param messages The messages to send to the agent + * @param structuredOutputSchema The JSON Schema requested by {@code text.format.type=json_schema} + * @param request The original Responses API request + * @param responseId The response ID used across the stream + * @return A {@link Flux} of Spring SSE events + */ + public Flux> streamAsSse( + ReActAgent agent, + List messages, + JsonNode structuredOutputSchema, + ResponsesRequest request, + String responseId) { + return streamingAdapter.stream(agent, messages, structuredOutputSchema, request, responseId) + .map(this::toSse); + } + + /** + * Stream agent events and observe each raw Responses event before Spring SSE serialization. + * + *

The controller uses this variant to persist the final {@code response.completed} or {@code + * response.failed} payload after the stream finishes. + * + * @param agent The agent to stream from + * @param messages The messages to send to the agent + * @param structuredOutputSchema Optional JSON Schema for structured output + * @param request The original Responses API request + * @param responseId The response ID used across the stream + * @param eventConsumer Observer invoked for each Responses stream event + * @return A {@link Flux} of Spring SSE events + */ + public Flux> streamAsSse( + ReActAgent agent, + List messages, + JsonNode structuredOutputSchema, + ResponsesRequest request, + String responseId, + Consumer eventConsumer) { + return streamingAdapter.stream(agent, messages, structuredOutputSchema, request, responseId) + .doOnNext(eventConsumer) + .map(this::toSse); + } + + /** + * Create an SSE event for a runtime failure after the request has entered streaming mode. + * + * @param error The error that occurred + * @param request The original Responses API request + * @param responseId The response ID used across the stream + * @return A {@link ServerSentEvent} wrapping a {@code response.failed} event + */ + public ServerSentEvent createErrorSseEvent( + Throwable error, ResponsesRequest request, String responseId) { + return toSse(streamingAdapter.createFailedEvent(error, request, responseId)); + } + + /** + * Convert a Responses stream event to a Spring SSE event. + * + * @param event The Responses stream event to serialize + * @return SSE event with the Responses event name and JSON data + */ + private ServerSentEvent toSse(ResponsesStreamEvent event) { + try { + return ServerSentEvent.builder() + .event(event.getType()) + .data(OBJECT_MAPPER.writeValueAsString(event)) + .build(); + } catch (JsonProcessingException e) { + return ServerSentEvent.builder() + .event("error") + .data("{\"error\":\"Serialization error\"}") + .build(); + } + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/web/ConversationsController.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/web/ConversationsController.java new file mode 100644 index 000000000..1ca42e0d2 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/web/ConversationsController.java @@ -0,0 +1,228 @@ +/* + * 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.spring.boot.responses.web; + +import io.agentscope.core.responses.builder.ResponsesResponseBuilder; +import io.agentscope.core.responses.converter.ResponsesValidationException; +import io.agentscope.core.responses.model.ResponsesConversationItemsRequest; +import io.agentscope.core.responses.model.ResponsesConversationRequest; +import io.agentscope.spring.boot.responses.service.ResponsesStateService; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP controller exposing the OpenAI Conversations-compatible API. + * + *

This controller manages conversation metadata and conversation item resources used by the + * Responses API. The default backing store is the in-memory {@link ResponsesStateService}; replace + * that bean in application code when conversations need durable storage or cross-instance sharing. + * + *

How It Works: + * + *

    + *
  1. Clients create or retrieve a conversation resource + *
  2. Clients add, list, retrieve, or delete conversation items + *
  3. {@code POST /v1/responses} can reference the conversation ID to include prior items as + * input context + *
  4. Completed response input/output items are appended back to the conversation + *
+ */ +@RestController +@RequestMapping +public class ConversationsController { + + private final ResponsesStateService stateService; + private final ResponsesResponseBuilder responseBuilder; + + /** + * Constructs a new {@code ConversationsController}. + * + * @param stateService Service for conversation state + * @param responseBuilder Builder for structured error responses + */ + public ConversationsController( + ResponsesStateService stateService, ResponsesResponseBuilder responseBuilder) { + this.stateService = stateService; + this.responseBuilder = responseBuilder; + } + + /** + * Create a conversation resource. + * + * @param request Optional metadata and initial items + * @return The created conversation object + */ + @PostMapping( + value = "${agentscope.conversations.base-path:/v1/conversations}", + consumes = MediaType.APPLICATION_JSON_VALUE) + public Object createConversation( + @Valid @RequestBody(required = false) ResponsesConversationRequest request) { + return ResponseEntity.ok(stateService.createConversation(request)); + } + + /** + * Retrieve an existing conversation. + * + * @param conversationId Conversation ID + * @return The conversation object, or a structured not-found error + */ + @GetMapping(value = "${agentscope.conversations.base-path:/v1/conversations}/{conversationId}") + public Object retrieveConversation(@PathVariable String conversationId) { + try { + return ResponseEntity.ok(stateService.retrieveConversation(conversationId)); + } catch (ResponsesValidationException e) { + return ResponseEntity.status(404).body(responseBuilder.buildErrorResponse(e)); + } + } + + /** + * Update conversation metadata. + * + * @param conversationId Conversation ID + * @param request Replacement metadata payload + * @return The updated conversation object + */ + @PostMapping( + value = "${agentscope.conversations.base-path:/v1/conversations}/{conversationId}", + consumes = MediaType.APPLICATION_JSON_VALUE) + public Object updateConversation( + @PathVariable String conversationId, + @Valid @RequestBody(required = false) ResponsesConversationRequest request) { + try { + return ResponseEntity.ok(stateService.updateConversation(conversationId, request)); + } catch (ResponsesValidationException e) { + return ResponseEntity.status(404).body(responseBuilder.buildErrorResponse(e)); + } + } + + /** + * Delete a conversation resource. + * + * @param conversationId Conversation ID + * @return Deletion status + */ + @DeleteMapping( + value = "${agentscope.conversations.base-path:/v1/conversations}/{conversationId}") + public Object deleteConversation(@PathVariable String conversationId) { + try { + return ResponseEntity.ok(stateService.deleteConversation(conversationId)); + } catch (ResponsesValidationException e) { + return ResponseEntity.status(404).body(responseBuilder.buildErrorResponse(e)); + } + } + + /** + * List conversation items with optional cursor pagination. + * + * @param conversationId Conversation ID + * @param after Cursor item ID + * @param limit Maximum item count + * @param order Sort order, either {@code asc} or {@code desc} + * @return An OpenAI-style list object + */ + @GetMapping( + value = + "${agentscope.conversations.base-path:/v1/conversations}/{conversationId}/items") + public Object listConversationItems( + @PathVariable String conversationId, + @RequestParam(required = false) String after, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) String order) { + try { + return ResponseEntity.ok( + stateService.listConversationItems(conversationId, after, limit, order)); + } catch (ResponsesValidationException e) { + return ResponseEntity.status(responseStatus(e)) + .body(responseBuilder.buildErrorResponse(e)); + } + } + + /** + * Append items to a conversation. + * + * @param conversationId Conversation ID + * @param request Items to append + * @return An OpenAI-style list containing the created items + */ + @PostMapping( + value = + "${agentscope.conversations.base-path:/v1/conversations}/{conversationId}/items", + consumes = MediaType.APPLICATION_JSON_VALUE) + public Object createConversationItems( + @PathVariable String conversationId, + @Valid @RequestBody(required = false) ResponsesConversationItemsRequest request) { + try { + return ResponseEntity.ok( + stateService.createConversationItems( + conversationId, request != null ? request.getItems() : null)); + } catch (ResponsesValidationException e) { + return ResponseEntity.status(404).body(responseBuilder.buildErrorResponse(e)); + } + } + + /** + * Retrieve a single conversation item. + * + * @param conversationId Conversation ID + * @param itemId Conversation item ID + * @return The requested item, or a structured not-found error + */ + @GetMapping( + value = + "${agentscope.conversations.base-path:/v1/conversations}/{conversationId}/items/{itemId}") + public Object retrieveConversationItem( + @PathVariable String conversationId, @PathVariable String itemId) { + try { + return ResponseEntity.ok(stateService.retrieveConversationItem(conversationId, itemId)); + } catch (ResponsesValidationException e) { + return ResponseEntity.status(404).body(responseBuilder.buildErrorResponse(e)); + } + } + + /** + * Delete a single conversation item. + * + * @param conversationId Conversation ID + * @param itemId Conversation item ID + * @return Deletion status + */ + @DeleteMapping( + value = + "${agentscope.conversations.base-path:/v1/conversations}/{conversationId}/items/{itemId}") + public Object deleteConversationItem( + @PathVariable String conversationId, @PathVariable String itemId) { + try { + return ResponseEntity.ok(stateService.deleteConversationItem(conversationId, itemId)); + } catch (ResponsesValidationException e) { + return ResponseEntity.status(404).body(responseBuilder.buildErrorResponse(e)); + } + } + + private org.springframework.http.HttpStatus responseStatus(ResponsesValidationException e) { + return "not_found".equals(e.getCode()) + ? org.springframework.http.HttpStatus.NOT_FOUND + : org.springframework.http.HttpStatus.BAD_REQUEST; + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/web/ResponsesController.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/web/ResponsesController.java new file mode 100644 index 000000000..2fb2617ae --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/java/io/agentscope/spring/boot/responses/web/ResponsesController.java @@ -0,0 +1,486 @@ +/* + * 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.spring.boot.responses.web; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.message.Msg; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.responses.builder.ResponsesResponseBuilder; +import io.agentscope.core.responses.converter.ResponsesConversionResult; +import io.agentscope.core.responses.converter.ResponsesGenerationOptionsConverter; +import io.agentscope.core.responses.converter.ResponsesInputConverter; +import io.agentscope.core.responses.converter.ResponsesToolConverter; +import io.agentscope.core.responses.converter.ResponsesValidationException; +import io.agentscope.core.responses.hook.ResponsesRequestHook; +import io.agentscope.core.responses.model.ResponsesDeletionStatus; +import io.agentscope.core.responses.model.ResponsesList; +import io.agentscope.core.responses.model.ResponsesRequest; +import io.agentscope.core.responses.model.ResponsesResponse; +import io.agentscope.core.responses.model.ResponsesTokenCount; +import io.agentscope.spring.boot.responses.service.ResponsesStateService; +import io.agentscope.spring.boot.responses.service.ResponsesStateService.PreparedRequest; +import io.agentscope.spring.boot.responses.service.ResponsesStreamingService; +import jakarta.validation.Valid; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * HTTP controller exposing a Responses API compatible with OpenAI's standard. + * + *

This controller exposes both the create endpoint and stateful resources backed by {@link + * ResponsesStateService}. The default state service is in-memory; applications can replace it with a + * durable bean when they need persistence across process restarts or multiple application instances. + * + *

How It Works: + * + *

    + *
  1. Client sends {@code input}, optional {@code instructions}, optional tools, and optional + * output formatting + *
  2. Controller converts the HTTP DTO into AgentScope messages and request-scoped options + *
  3. Server creates a fresh {@link ReActAgent}, registers schema-only tools when requested, and + * attaches per-request options + *
  4. Agent runs in normal, structured-output, or streaming mode + *
  5. Response builder returns a Responses-shaped JSON object or Responses-style SSE events + *
+ * + *

Tool Calls: + * + *

Function tools are registered as schema-only tools. When the model chooses a tool, the + * response includes a {@code function_call} output item. The client executes the tool externally + * and sends a {@code function_call_output} item in the next request. + * + *

Features: + * + *

    + *
  • Non-streaming JSON response by default + *
  • SSE streaming when {@code stream=true} or the streaming route is selected + *
  • Stored responses, previous-response context, background responses, and conversations + *
  • Text, image, audio, video, file-reference, function-call, and opaque item input handling + *
  • Schema-only function tool calls and client-provided tool outputs + *
  • JSON Schema structured output in both non-streaming and streaming modes + *
+ */ +@RestController +@RequestMapping +public class ResponsesController { + + private static final Logger log = LoggerFactory.getLogger(ResponsesController.class); + + private final ObjectProvider agentProvider; + private final ResponsesInputConverter inputConverter; + private final ResponsesToolConverter toolConverter; + private final ResponsesGenerationOptionsConverter generationOptionsConverter; + private final ResponsesResponseBuilder responseBuilder; + private final ResponsesStreamingService streamingService; + private final ResponsesStateService stateService; + + /** + * Constructs a new {@code ResponsesController}. + * + * @param agentProvider Provider for creating prototype-scoped agent instances + * @param inputConverter Converter for Responses request DTOs to AgentScope messages + * @param toolConverter Converter for Responses function tools to ToolSchema + * @param generationOptionsConverter Converter for request generation parameters + * @param responseBuilder Builder for Responses API response objects + * @param streamingService Service for converting stream events to Spring SSE events + * @param stateService Service for stored responses, previous response context, and + * conversations + */ + public ResponsesController( + ObjectProvider agentProvider, + ResponsesInputConverter inputConverter, + ResponsesToolConverter toolConverter, + ResponsesGenerationOptionsConverter generationOptionsConverter, + ResponsesResponseBuilder responseBuilder, + ResponsesStreamingService streamingService, + ResponsesStateService stateService) { + this.agentProvider = agentProvider; + this.inputConverter = inputConverter; + this.toolConverter = toolConverter; + this.generationOptionsConverter = generationOptionsConverter; + this.responseBuilder = responseBuilder; + this.streamingService = streamingService; + this.stateService = stateService; + } + + /** + * Responses creation endpoint. + * + *

Processes the request input and returns a Responses API object. If the request has {@code + * stream=true}, this method automatically switches to streaming mode even without an {@code + * Accept: text/event-stream} header for better client compatibility. + * + * @param request The Responses API request + * @return A {@link Mono} containing a {@link ResponsesResponse}, or a {@link Flux} of {@link + * ServerSentEvent} if streaming is requested + */ + @PostMapping( + value = "${agentscope.responses.base-path:/v1/responses}", + consumes = MediaType.APPLICATION_JSON_VALUE) + public Object createResponse(@Valid @RequestBody ResponsesRequest request) { + String responseId = responseId(); + try { + PreparedRequest prepared = stateService.prepare(request); + ResponsesConversionResult conversion = inputConverter.convert(prepared.request()); + if (Boolean.TRUE.equals(prepared.request().getStream())) { + Flux> stream = + createResponseStream(prepared.request(), conversion, responseId, prepared); + return ResponseEntity.ok() + .header("Content-Type", MediaType.TEXT_EVENT_STREAM_VALUE) + .body(stream); + } + if (Boolean.TRUE.equals(prepared.request().getBackground())) { + return createBackgroundResponse( + prepared.request(), conversion, responseId, prepared); + } + return createNonStreamingResponse(prepared.request(), conversion, responseId) + .map(response -> stateService.save(response, prepared)); + } catch (ResponsesValidationException e) { + return ResponseEntity.badRequest().body(responseBuilder.buildErrorResponse(e)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(responseBuilder.buildInvalidRequestError(e)); + } + } + + /** + * Streaming Responses endpoint. + * + *

Processes the request input and streams Responses API Server-Sent Events. This route also + * supports {@code text.format.type=json_schema}; the structured result is emitted as standard + * Responses output text events. + * + * @param request The Responses API request + * @return A {@link ResponseEntity} containing a {@link Flux} of {@link ServerSentEvent} objects + */ + @PostMapping( + value = "${agentscope.responses.base-path:/v1/responses}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Object createResponseStream(@Valid @RequestBody ResponsesRequest request) { + String responseId = responseId(); + try { + PreparedRequest prepared = stateService.prepare(request); + ResponsesConversionResult conversion = inputConverter.convert(prepared.request()); + return ResponseEntity.ok() + .header("Content-Type", MediaType.TEXT_EVENT_STREAM_VALUE) + .body( + createResponseStream( + prepared.request(), conversion, responseId, prepared)); + } catch (ResponsesValidationException e) { + return ResponseEntity.badRequest().body(responseBuilder.buildErrorResponse(e)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(responseBuilder.buildInvalidRequestError(e)); + } + } + + /** + * Retrieve a stored response by ID. + * + * @param responseId Response ID + * @return Stored response, or a structured not-found error + */ + @GetMapping(value = "${agentscope.responses.base-path:/v1/responses}/{responseId}") + public Object retrieveResponse(@PathVariable String responseId) { + try { + return ResponseEntity.ok(stateService.retrieveResponse(responseId)); + } catch (ResponsesValidationException e) { + return ResponseEntity.status(404).body(responseBuilder.buildErrorResponse(e)); + } + } + + /** + * Delete a stored response by ID. + * + * @param responseId Response ID + * @return Deletion status, or a structured not-found error + */ + @DeleteMapping(value = "${agentscope.responses.base-path:/v1/responses}/{responseId}") + public Object deleteResponse(@PathVariable String responseId) { + try { + ResponsesDeletionStatus deleted = stateService.deleteResponse(responseId); + return ResponseEntity.ok(deleted); + } catch (ResponsesValidationException e) { + return ResponseEntity.status(404).body(responseBuilder.buildErrorResponse(e)); + } + } + + /** + * Cancel a stored or running background response. + * + * @param responseId Response ID + * @return Canceled response, or a structured not-found error + */ + @PostMapping(value = "${agentscope.responses.base-path:/v1/responses}/{responseId}/cancel") + public Object cancelResponse(@PathVariable String responseId) { + try { + return ResponseEntity.ok(stateService.cancelResponse(responseId)); + } catch (ResponsesValidationException e) { + return ResponseEntity.status(404).body(responseBuilder.buildErrorResponse(e)); + } + } + + /** + * List the input items originally stored with a response. + * + * @param responseId Response ID + * @param after Cursor item ID + * @param limit Maximum item count + * @param order Sort order, either {@code asc} or {@code desc} + * @return OpenAI-style list object + */ + @GetMapping(value = "${agentscope.responses.base-path:/v1/responses}/{responseId}/input_items") + public Object listResponseInputItems( + @PathVariable String responseId, + @RequestParam(required = false) String after, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) String order) { + try { + ResponsesList items = + stateService.responseInputItems(responseId, after, limit, order); + return ResponseEntity.ok(items); + } catch (ResponsesValidationException e) { + return ResponseEntity.status(responseStatus(e)) + .body(responseBuilder.buildErrorResponse(e)); + } + } + + /** + * Count input tokens approximately. + * + * @param request Request containing input to count + * @return Approximate token count + */ + @PostMapping( + value = "${agentscope.responses.base-path:/v1/responses}/input_tokens", + consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponsesTokenCount countResponseInputTokens( + @Valid @RequestBody ResponsesRequest request) { + return stateService.countInputTokens(request); + } + + /** + * Alias for clients that use the newer {@code /input_tokens/count} path. + * + * @param request Request containing input to count + * @return Approximate token count + */ + @PostMapping( + value = "${agentscope.responses.base-path:/v1/responses}/input_tokens/count", + consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponsesTokenCount countResponseInputTokensAlias( + @Valid @RequestBody ResponsesRequest request) { + return stateService.countInputTokens(request); + } + + /** + * Compact input by running a normal non-streaming response over the supplied context. + * + * @param request Request containing context to compact + * @return Completed compacted response, or a structured error + */ + @PostMapping( + value = "${agentscope.responses.base-path:/v1/responses}/compact", + consumes = MediaType.APPLICATION_JSON_VALUE) + public Object compactResponseInput(@Valid @RequestBody ResponsesRequest request) { + String responseId = responseId(); + try { + PreparedRequest prepared = stateService.prepare(request); + prepared.request().setStream(false); + ResponsesConversionResult conversion = inputConverter.convert(prepared.request()); + return createNonStreamingResponse(prepared.request(), conversion, responseId) + .map(response -> stateService.save(response, prepared)); + } catch (ResponsesValidationException e) { + return ResponseEntity.badRequest().body(responseBuilder.buildErrorResponse(e)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(responseBuilder.buildInvalidRequestError(e)); + } + } + + /** + * Run the agent in non-streaming mode and wrap the final message in Responses format. + * + * @param request Prepared Responses request + * @param conversion Converted messages and structured-output metadata + * @param responseId Response ID + * @return Mono containing completed or failed response + */ + private Mono createNonStreamingResponse( + ResponsesRequest request, ResponsesConversionResult conversion, String responseId) { + try { + ReActAgent agent = prepareAgent(request, conversion); + long startedAt = System.currentTimeMillis(); + Mono call = + conversion.structuredOutputSchema() != null + ? agent.call(conversion.messages(), conversion.structuredOutputSchema()) + : agent.call(conversion.messages()); + return call.map( + reply -> { + log.debug( + "Responses request completed: responseId={}, duration={}ms", + responseId, + System.currentTimeMillis() - startedAt); + if (conversion.structuredOutputSchema() != null) { + return responseBuilder.buildStructuredResponse( + request, reply, responseId); + } + return responseBuilder.buildResponse(request, reply, responseId); + }) + .onErrorResume( + error -> + Mono.just( + responseBuilder.buildFailedResponse( + request, error, responseId))); + } catch (Exception e) { + if (e instanceof ResponsesValidationException validationException) { + throw validationException; + } + return Mono.just(responseBuilder.buildFailedResponse(request, e, responseId)); + } + } + + /** + * Run the agent in streaming mode and persist the terminal response event. + * + * @param request Prepared Responses request + * @param conversion Converted messages and structured-output metadata + * @param responseId Response ID + * @param prepared Prepared state metadata for storage + * @return Flux of Spring SSE events + */ + private Flux> createResponseStream( + ResponsesRequest request, + ResponsesConversionResult conversion, + String responseId, + PreparedRequest prepared) { + try { + ReActAgent agent = prepareAgent(request, conversion); + return streamingService + .streamAsSse( + agent, + conversion.messages(), + conversion.structuredOutputSchema(), + request, + responseId, + event -> { + // The stream itself carries the final response object, so persist + // only when the adapter emits a terminal event. + if (event.getResponse() != null + && ("response.completed".equals(event.getType()) + || "response.failed".equals(event.getType()))) { + stateService.save(event.getResponse(), prepared); + } + }) + .onErrorResume( + error -> + Flux.just( + streamingService.createErrorSseEvent( + error, request, responseId))); + } catch (Exception e) { + if (e instanceof ResponsesValidationException validationException) { + throw validationException; + } + return Flux.just(streamingService.createErrorSseEvent(e, request, responseId)); + } + } + + /** + * Queue a background response and execute the real model call asynchronously. + * + * @param request Prepared Responses request + * @param conversion Converted messages and structured-output metadata + * @param responseId Response ID + * @param prepared Prepared state metadata for storage + * @return Mono containing the queued response placeholder + */ + private Mono createBackgroundResponse( + ResponsesRequest request, + ResponsesConversionResult conversion, + String responseId, + PreparedRequest prepared) { + ResponsesResponse queued = responseBuilder.baseResponse(request, responseId, "queued"); + queued.setBackground(true); + queued.setStore(true); + queued.setOutput(List.of()); + queued.setOutputText(""); + stateService.saveBackground(queued, prepared, null); + + // Store the subscription separately so /cancel can dispose it while the model call is still + // running. + Disposable task = + createNonStreamingResponse(request, conversion, responseId) + .map(response -> stateService.save(response, prepared)) + .subscribe(); + stateService.attachBackgroundTask(responseId, task); + return Mono.just(queued); + } + + /** + * Create and configure a fresh agent for one request. + * + * @param request Prepared Responses request + * @param conversion Converted messages and request-scoped system fragments + * @return Configured agent + */ + private ReActAgent prepareAgent( + ResponsesRequest request, ResponsesConversionResult conversion) { + ReActAgent agent = agentProvider.getObject(); + if (agent == null) { + throw new IllegalStateException("Failed to create ReActAgent: provider returned null"); + } + if (request.getTools() != null && !request.getTools().isEmpty()) { + agent.getToolkit() + .registerSchemas(toolConverter.convertToToolSchemas(request.getTools())); + } + GenerateOptions requestOptions = generationOptionsConverter.convert(request); + ResponsesRequestHook hook = + new ResponsesRequestHook(conversion.systemFragments(), requestOptions); + List hooks = agent.getHooks(); + // The starter uses a prototype agent, but hooks are still sorted to respect application + // hook priority when users add their own request customizations. + hooks.add(hook); + hooks.sort(Comparator.comparingInt(Hook::priority)); + return agent; + } + + private String responseId() { + return "resp_" + UUID.randomUUID(); + } + + private org.springframework.http.HttpStatus responseStatus(ResponsesValidationException e) { + return "not_found".equals(e.getCode()) + ? org.springframework.http.HttpStatus.NOT_FOUND + : org.springframework.http.HttpStatus.BAD_REQUEST; + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..f286498af --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# +# 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. +# +io.agentscope.spring.boot.responses.config.ResponsesWebAutoConfiguration diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/test/java/io/agentscope/spring/boot/responses/config/ResponsesWebAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/test/java/io/agentscope/spring/boot/responses/config/ResponsesWebAutoConfigurationTest.java new file mode 100644 index 000000000..ee00f52ca --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/test/java/io/agentscope/spring/boot/responses/config/ResponsesWebAutoConfigurationTest.java @@ -0,0 +1,79 @@ +/* + * 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.spring.boot.responses.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.agentscope.core.responses.builder.ResponsesResponseBuilder; +import io.agentscope.core.responses.converter.ResponsesGenerationOptionsConverter; +import io.agentscope.core.responses.converter.ResponsesInputConverter; +import io.agentscope.core.responses.converter.ResponsesToolConverter; +import io.agentscope.core.responses.streaming.ResponsesStreamingAdapter; +import io.agentscope.spring.boot.responses.service.ResponsesStateService; +import io.agentscope.spring.boot.responses.service.ResponsesStreamingService; +import io.agentscope.spring.boot.responses.web.ConversationsController; +import io.agentscope.spring.boot.responses.web.ResponsesController; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class ResponsesWebAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ResponsesWebAutoConfiguration.class)); + + @Test + void shouldCreateResponsesApiBeansByDefault() { + contextRunner.run( + context -> { + assertThat(context).hasSingleBean(ResponsesInputConverter.class); + assertThat(context).hasSingleBean(ResponsesToolConverter.class); + assertThat(context).hasSingleBean(ResponsesGenerationOptionsConverter.class); + assertThat(context).hasSingleBean(ResponsesResponseBuilder.class); + assertThat(context).hasSingleBean(ResponsesStreamingAdapter.class); + assertThat(context).hasSingleBean(ResponsesStreamingService.class); + assertThat(context).hasSingleBean(ResponsesStateService.class); + assertThat(context).hasSingleBean(ResponsesController.class); + assertThat(context).hasSingleBean(ConversationsController.class); + assertThat(context).hasSingleBean(ResponsesProperties.class); + assertThat(context.getBean(ResponsesProperties.class).getBasePath()) + .isEqualTo("/v1/responses"); + }); + } + + @Test + void shouldBindCustomBasePath() { + contextRunner + .withPropertyValues("agentscope.responses.base-path=/custom/responses") + .run( + context -> + assertThat(context.getBean(ResponsesProperties.class).getBasePath()) + .isEqualTo("/custom/responses")); + } + + @Test + void shouldNotCreateResponsesApiBeansWhenDisabled() { + contextRunner + .withPropertyValues("agentscope.responses.enabled=false") + .run( + context -> { + assertThat(context).doesNotHaveBean(ResponsesInputConverter.class); + assertThat(context).doesNotHaveBean(ResponsesController.class); + assertThat(context).doesNotHaveBean(ConversationsController.class); + }); + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/test/java/io/agentscope/spring/boot/responses/web/ConversationsControllerTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/test/java/io/agentscope/spring/boot/responses/web/ConversationsControllerTest.java new file mode 100644 index 000000000..d2fc03b20 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/test/java/io/agentscope/spring/boot/responses/web/ConversationsControllerTest.java @@ -0,0 +1,107 @@ +/* + * 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.spring.boot.responses.web; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.agentscope.core.responses.builder.ResponsesResponseBuilder; +import io.agentscope.core.responses.model.ResponsesConversation; +import io.agentscope.core.responses.model.ResponsesConversationItemsRequest; +import io.agentscope.core.responses.model.ResponsesConversationRequest; +import io.agentscope.spring.boot.responses.service.ResponsesStateService; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +class ConversationsControllerTest { + + private ConversationsController controller; + + @BeforeEach + void setUp() { + controller = + new ConversationsController( + new ResponsesStateService(), new ResponsesResponseBuilder()); + } + + @Test + void shouldCreateRetrieveUpdateAndDeleteConversation() { + ResponsesConversationRequest request = new ResponsesConversationRequest(); + request.setMetadata(Map.of("user", "alice")); + + ResponseEntity createdEntity = + (ResponseEntity) controller.createConversation(request); + assertThat(createdEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + ResponsesConversation created = (ResponsesConversation) createdEntity.getBody(); + assertThat(created.getId()).startsWith("conv_"); + + ResponseEntity retrieved = + (ResponseEntity) controller.retrieveConversation(created.getId()); + assertThat(retrieved.getStatusCode()).isEqualTo(HttpStatus.OK); + + ResponsesConversationRequest update = new ResponsesConversationRequest(); + update.setMetadata(Map.of("user", "bob")); + ResponseEntity updated = + (ResponseEntity) controller.updateConversation(created.getId(), update); + assertThat(((ResponsesConversation) updated.getBody()).getMetadata()) + .containsEntry("user", "bob"); + + ResponseEntity deleted = + (ResponseEntity) controller.deleteConversation(created.getId()); + assertThat(deleted.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void shouldCreateRetrieveListAndDeleteConversationItems() { + ResponsesConversation created = + (ResponsesConversation) + ((ResponseEntity) controller.createConversation(null)).getBody(); + ResponsesConversationItemsRequest itemRequest = new ResponsesConversationItemsRequest(); + itemRequest.setItems( + List.of(Map.of("type", "message", "role", "user", "content", "Hello"))); + + ResponseEntity createdItems = + (ResponseEntity) + controller.createConversationItems(created.getId(), itemRequest); + assertThat(createdItems.getStatusCode()).isEqualTo(HttpStatus.OK); + + ResponseEntity list = + (ResponseEntity) + controller.listConversationItems(created.getId(), null, null, null); + assertThat(list.getStatusCode()).isEqualTo(HttpStatus.OK); + + @SuppressWarnings("unchecked") + Map item = + (Map) + ((io.agentscope.core.responses.model.ResponsesList) + createdItems.getBody()) + .getData() + .get(0); + ResponseEntity retrieved = + (ResponseEntity) + controller.retrieveConversationItem( + created.getId(), (String) item.get("id")); + assertThat(retrieved.getStatusCode()).isEqualTo(HttpStatus.OK); + + ResponseEntity deleted = + (ResponseEntity) + controller.deleteConversationItem(created.getId(), (String) item.get("id")); + assertThat(deleted.getStatusCode()).isEqualTo(HttpStatus.OK); + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/test/java/io/agentscope/spring/boot/responses/web/ResponsesControllerTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/test/java/io/agentscope/spring/boot/responses/web/ResponsesControllerTest.java new file mode 100644 index 000000000..12587ba4c --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter/src/test/java/io/agentscope/spring/boot/responses/web/ResponsesControllerTest.java @@ -0,0 +1,369 @@ +/* + * 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.spring.boot.responses.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.Event; +import io.agentscope.core.agent.EventType; +import io.agentscope.core.agent.StreamOptions; +import io.agentscope.core.message.MessageMetadataKeys; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.responses.builder.ResponsesResponseBuilder; +import io.agentscope.core.responses.converter.ResponsesGenerationOptionsConverter; +import io.agentscope.core.responses.converter.ResponsesInputConverter; +import io.agentscope.core.responses.converter.ResponsesToolConverter; +import io.agentscope.core.responses.model.ResponsesList; +import io.agentscope.core.responses.model.ResponsesRequest; +import io.agentscope.core.responses.model.ResponsesResponse; +import io.agentscope.core.responses.streaming.ResponsesStreamingAdapter; +import io.agentscope.spring.boot.responses.service.ResponsesStateService; +import io.agentscope.spring.boot.responses.service.ResponsesStreamingService; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +class ResponsesControllerTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private ObjectProvider agentProvider; + private ResponsesController controller; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ResponsesToolConverter toolConverter = new ResponsesToolConverter(); + ResponsesResponseBuilder responseBuilder = new ResponsesResponseBuilder(); + ResponsesStreamingService streamingService = + new ResponsesStreamingService(new ResponsesStreamingAdapter(responseBuilder)); + ResponsesStateService stateService = new ResponsesStateService(); + agentProvider = mock(ObjectProvider.class); + controller = + new ResponsesController( + agentProvider, + new ResponsesInputConverter(), + toolConverter, + new ResponsesGenerationOptionsConverter(toolConverter), + responseBuilder, + streamingService, + stateService); + } + + @Test + @SuppressWarnings("unchecked") + void shouldStreamJsonSchemaFromJsonEndpoint() throws Exception { + ReActAgent agent = prepareStructuredStreamingAgent(); + + Object response = controller.createResponse(streamingJsonSchemaRequest()); + + assertThat(response).isInstanceOf(ResponseEntity.class); + ResponseEntity entity = (ResponseEntity) response; + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(entity.getBody()).isInstanceOf(Flux.class); + + List> events = + ((Flux>) entity.getBody()).collectList().block(); + assertThat(events).isNotNull(); + assertThat(events).extracting(ServerSentEvent::event).contains("response.completed"); + assertThat(events) + .extracting(ServerSentEvent::event) + .contains("response.output_text.delta"); + verify(agent).stream(anyList(), any(StreamOptions.class), any(JsonNode.class)); + } + + @Test + void shouldReturnNotFoundForUnknownPreviousResponseIdThroughSpringMvc() throws Exception { + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + mockMvc.perform( + post("/v1/responses") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "model": "gpt-4.1-mini", + "previous_response_id": "resp_old", + "input": "Hello" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("not_found")) + .andExpect(jsonPath("$.error.param").value("response_id")); + + verifyNoInteractions(agentProvider); + } + + @Test + @SuppressWarnings("unchecked") + void shouldStoreResponseAndUsePreviousResponseId() throws Exception { + ReActAgent agent = prepareTextAgent("First answer", "Second answer"); + + ResponsesResponse first = + responseMono( + controller.createResponse( + request( + """ + { + "input": "Hello", + "store": true + } + """))) + .block(); + assertThat(first).isNotNull(); + assertThat(first.getStore()).isTrue(); + + Object retrieved = controller.retrieveResponse(first.getId()); + assertThat(retrieved).isInstanceOf(ResponseEntity.class); + assertThat(((ResponseEntity) retrieved).getStatusCode()).isEqualTo(HttpStatus.OK); + + ResponsesResponse second = + responseMono( + controller.createResponse( + request( + """ + { + "input": "Continue", + "previous_response_id": "%s" + } + """ + .formatted(first.getId())))) + .block(); + + assertThat(second).isNotNull(); + assertThat(second.getPreviousResponseId()).isEqualTo(first.getId()); + assertThat(second.getOutputText()).isEqualTo("Second answer"); + ArgumentCaptor> messagesCaptor = ArgumentCaptor.forClass(List.class); + verify(agent, times(2)).call(messagesCaptor.capture()); + assertThat(messagesCaptor.getAllValues().get(1).size()) + .isGreaterThan(messagesCaptor.getAllValues().get(0).size()); + } + + @Test + void shouldCreateQueuedBackgroundResponse() throws Exception { + prepareTextAgent("Background answer"); + + ResponsesResponse response = + responseMono( + controller.createResponse( + request( + """ + { + "input": "Run later", + "background": true + } + """))) + .block(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo("queued"); + assertThat(response.getBackground()).isTrue(); + assertThat(response.getStore()).isTrue(); + assertThat(controller.retrieveResponse(response.getId())) + .isInstanceOf(ResponseEntity.class); + } + + @Test + void shouldReturnStoredResponseInputItems() throws Exception { + prepareTextAgent("Stored"); + + ResponsesResponse response = + responseMono( + controller.createResponse( + request( + """ + { + "input": "Remember this", + "store": true + } + """))) + .block(); + + Object result = controller.listResponseInputItems(response.getId(), null, null, null); + assertThat(result).isInstanceOf(ResponseEntity.class); + assertThat(((ResponseEntity) result).getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void shouldCompactResponseInputThroughAgentCall() throws Exception { + prepareTextAgent("Compacted answer"); + + ResponsesResponse response = + responseMono( + controller.compactResponseInput( + request( + """ + { + "input": "Summarize this context" + } + """))) + .block(); + + assertThat(response).isNotNull(); + assertThat(response.getOutputText()).isEqualTo("Compacted answer"); + } + + @Test + @SuppressWarnings("unchecked") + void shouldPageStoredResponseInputItems() throws Exception { + prepareTextAgent("Stored"); + + ResponsesResponse response = + responseMono( + controller.createResponse( + request( + """ + { + "input": [ + {"role": "user", "content": "First"}, + {"role": "user", "content": "Second"} + ], + "store": true + } + """))) + .block(); + + ResponseEntity result = + (ResponseEntity) + controller.listResponseInputItems(response.getId(), null, 1, "asc"); + ResponsesList page = (ResponsesList) result.getBody(); + assertThat(page.getData()).hasSize(1); + assertThat(page.isHasMore()).isTrue(); + } + + @Test + @SuppressWarnings("unchecked") + void shouldStreamJsonSchemaFromSseEndpoint() throws Exception { + ReActAgent agent = prepareStructuredStreamingAgent(); + + Object response = controller.createResponseStream(streamingJsonSchemaRequest()); + + assertThat(response).isInstanceOf(ResponseEntity.class); + ResponseEntity entity = (ResponseEntity) response; + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(entity.getBody()).isInstanceOf(Flux.class); + + List> events = + ((Flux>) entity.getBody()).collectList().block(); + assertThat(events).isNotNull(); + assertThat(events).extracting(ServerSentEvent::event).contains("response.completed"); + assertThat(events) + .extracting(ServerSentEvent::event) + .contains("response.output_text.delta"); + verify(agent).stream(anyList(), any(StreamOptions.class), any(JsonNode.class)); + } + + private ReActAgent prepareStructuredStreamingAgent() { + ReActAgent agent = mock(ReActAgent.class); + when(agentProvider.getObject()).thenReturn(agent); + when(agent.getHooks()).thenReturn(new ArrayList<>()); + when(agent.stream(anyList(), any(StreamOptions.class), any(JsonNode.class))) + .thenReturn( + Flux.just(new Event(EventType.AGENT_RESULT, structuredAssistant(), true))); + return agent; + } + + private ReActAgent prepareTextAgent(String... replies) { + ReActAgent agent = mock(ReActAgent.class); + when(agentProvider.getObject()).thenReturn(agent); + when(agent.getHooks()).thenAnswer(invocation -> new ArrayList<>()); + @SuppressWarnings("unchecked") + Mono[] monos = new Mono[replies.length]; + for (int i = 0; i < replies.length; i++) { + monos[i] = Mono.just(assistantText(replies[i])); + } + when(agent.call(anyList())) + .thenReturn(monos[0], Arrays.copyOfRange(monos, 1, monos.length)); + return agent; + } + + private Msg structuredAssistant() { + Map structuredOutput = new LinkedHashMap<>(); + structuredOutput.put("answer", "42"); + return Msg.builder() + .role(MsgRole.ASSISTANT) + .metadata(Map.of(MessageMetadataKeys.STRUCTURED_OUTPUT, structuredOutput)) + .build(); + } + + private Msg assistantText(String text) { + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + private ResponsesRequest streamingJsonSchemaRequest() throws Exception { + return OBJECT_MAPPER.readValue( + """ + { + "input": "Return JSON", + "stream": true, + "text": { + "format": { + "type": "json_schema", + "schema": { + "type": "object", + "properties": { + "answer": {"type": "string"} + }, + "required": ["answer"] + } + } + } + } + """, + ResponsesRequest.class); + } + + private ResponsesRequest request(String json) throws Exception { + return OBJECT_MAPPER.readValue(json, ResponsesRequest.class); + } + + @SuppressWarnings("unchecked") + private Mono responseMono(Object response) { + return (Mono) response; + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/pom.xml b/agentscope-extensions/agentscope-spring-boot-starters/pom.xml index 2ad6a35d8..fe40e28fb 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/pom.xml +++ b/agentscope-extensions/agentscope-spring-boot-starters/pom.xml @@ -41,6 +41,7 @@ agentscope-a2a-spring-boot-starter agentscope-agui-spring-boot-starter agentscope-chat-completions-web-starter + agentscope-responses-web-starter agentscope-nacos-spring-boot-starter diff --git a/agentscope-extensions/pom.xml b/agentscope-extensions/pom.xml index 7bbc8f11b..2e2e9490b 100644 --- a/agentscope-extensions/pom.xml +++ b/agentscope-extensions/pom.xml @@ -52,6 +52,7 @@ agentscope-spring-boot-starters agentscope-extensions-agui agentscope-extensions-chat-completions-web + agentscope-extensions-responses-web agentscope-extensions-agent-protocol agentscope-extensions-higress agentscope-extensions-kotlin diff --git a/docs/_toc.yml b/docs/_toc.yml index 1ef691e3e..c12f12dd2 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -56,6 +56,8 @@ parts: title: Multimodal - file: en/task/structured-output title: Structured Output + - file: en/task/responses-api + title: Responses API - file: en/task/observability title: Observability & Studio - file: en/task/agui @@ -180,6 +182,8 @@ parts: title: 多模态 - file: zh/task/structured-output title: 结构化输出 + - file: zh/task/responses-api + title: Responses API - file: zh/task/observability title: 可观测与调试 - file: zh/task/agui diff --git a/docs/en/quickstart/installation.md b/docs/en/quickstart/installation.md index 7152b7632..c17790cde 100644 --- a/docs/en/quickstart/installation.md +++ b/docs/en/quickstart/installation.md @@ -226,6 +226,7 @@ Additional starters: | agentscope-a2a-spring-boot-starter | A2A Integration | `io.agentscope:agentscope-a2a-spring-boot-starter` | | agentscope-agui-spring-boot-starter | AG-UI Integration | `io.agentscope:agentscope-agui-spring-boot-starter` | | agentscope-chat-completions-web-starter | Chat Completions Web Integration | `io.agentscope:agentscope-chat-completions-web-starter` | +| agentscope-responses-web-starter | Responses Web Integration | `io.agentscope:agentscope-responses-web-starter` | | agentscope-nacos-spring-boot-starter | Nacos Integration | `io.agentscope:agentscope-nacos-spring-boot-starter` | ### Quarkus diff --git a/docs/en/task/responses-api.md b/docs/en/task/responses-api.md new file mode 100644 index 000000000..9ea27d1fe --- /dev/null +++ b/docs/en/task/responses-api.md @@ -0,0 +1,464 @@ +# Responses API + +AgentScope Java can expose OpenAI Responses-compatible HTTP APIs through +`agentscope-responses-web-starter`. The starter is additive and does not change the existing Chat +Completions starter. + +Official references: + +- https://developers.openai.com/api/reference/resources/responses +- https://developers.openai.com/api/reference/responses/overview +- https://developers.openai.com/api/reference/resources/responses/streaming-events +- https://developers.openai.com/api/reference/resources/conversations +- https://developers.openai.com/api/reference/resources/conversations/subresources/items + +## Scope + +The starter provides: + +- Responses create/retrieve/delete/cancel/input-items/token-count/compact endpoints +- Conversations create/retrieve/update/delete endpoints +- Conversation items create/list/retrieve/delete endpoints +- Non-streaming JSON responses +- Responses-style Server-Sent Events when `stream=true` +- JSON Schema structured output in non-streaming and streaming modes +- `previous_response_id`, `conversation`, `store`, and `background` stateful behavior +- Text, image, file-reference, audio, video, function-call, and opaque official item handling +- Function tool schema registration for external client-side tool loops +- API-shape acceptance for hosted tools such as web search, file search, code interpreter, MCP, + computer use, image generation, and custom tools + +The default state backend is in-memory. Replace `ResponsesStateService` with an application bean if +you need durable state across process restarts or multiple application instances. + +Hosted OpenAI tools are accepted at the request/DTO layer, but the default starter does not execute +OpenAI-hosted services by itself. Wire those tools into AgentScope or your application toolkit when +you need real execution. + +## Dependency + +```xml + + + io.agentscope + agentscope-spring-boot-starter + + + io.agentscope + agentscope-responses-web-starter + ${agentscope.version} + + +``` + +## Configuration + +```yaml +agentscope: + model: + provider: dashscope + dashscope: + enabled: true + api-key: ${DASHSCOPE_API_KEY} + model-name: qwen3-max + stream: true + agent: + enabled: true + name: "ResponsesAgent" + sys-prompt: "You are a helpful assistant." + max-iters: 8 + responses: + enabled: true + base-path: /v1/responses + conversations: + base-path: /v1/conversations +``` + +The starter expects a `ReActAgent` bean from `agentscope-spring-boot-starter`. Each request obtains +a fresh agent instance through `ObjectProvider`, while response/conversation state is +managed by `ResponsesStateService`. + +## Endpoints + +Responses: + +- `POST /v1/responses` +- `GET /v1/responses/{response_id}` +- `DELETE /v1/responses/{response_id}` +- `POST /v1/responses/{response_id}/cancel` +- `GET /v1/responses/{response_id}/input_items` +- `POST /v1/responses/input_tokens` +- `POST /v1/responses/input_tokens/count` +- `POST /v1/responses/compact` + +Conversations: + +- `POST /v1/conversations` +- `GET /v1/conversations/{conversation_id}` +- `POST /v1/conversations/{conversation_id}` +- `DELETE /v1/conversations/{conversation_id}` +- `GET /v1/conversations/{conversation_id}/items` +- `POST /v1/conversations/{conversation_id}/items` +- `GET /v1/conversations/{conversation_id}/items/{item_id}` +- `DELETE /v1/conversations/{conversation_id}/items/{item_id}` + +List endpoints support `after`, `limit`, and `order=asc|desc`. + +## Running The Example + +Start the sample app: + +```bash +export DASHSCOPE_API_KEY=your_key +mvn -pl agentscope-examples/responses-web -am -DskipTests package +java -jar agentscope-examples/responses-web/target/responses-web-1.1.0-SNAPSHOT.jar \ + --server.port=8080 +``` + +If you run it from IntelliJ IDEA, set `DASHSCOPE_API_KEY` in the run configuration and use +`--server.port=8080` as program arguments. + +The examples below do not require `jq`. Use `python3 -m json.tool` to pretty-print JSON: + +```bash +curl -s http://localhost:8080/v1/responses/resp_xxx | python3 -m json.tool +``` + +Extract an ID from a saved JSON file: + +```bash +python3 -c 'import json; print(json.load(open("/tmp/response.json"))["id"])' +``` + +## Core Examples + +Plain text: + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "input": "Briefly introduce AgentScope Java.", + "store": true + }' | tee /tmp/response.json | python3 -m json.tool +``` + +Expected result: + +- `object` is `response` +- `status` is `completed` +- `id` starts with `resp_` +- `output` and `output_text` are present +- no top-level `error` is present + +Retrieve a stored response: + +```bash +RESP_ID=$(python3 -c 'import json; print(json.load(open("/tmp/response.json"))["id"])') +curl -s "http://localhost:8080/v1/responses/${RESP_ID}" | python3 -m json.tool +``` + +Continue from a previous response: + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d "{ + \"model\": \"qwen3-max\", + \"previous_response_id\": \"${RESP_ID}\", + \"input\": \"Continue from the previous response in one sentence.\" + }" | python3 -m json.tool +``` + +Streaming: + +```bash +curl -N -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -H 'Accept: text/event-stream' \ + -d '{ + "model": "qwen3-max", + "stream": true, + "input": "Describe AgentScope Java in three short sentences." + }' +``` + +The stream uses Responses-style SSE events. A healthy stream includes events such as +`response.created`, `response.in_progress`, `response.output_item.added`, +`response.output_text.delta`, `response.output_text.done`, `response.output_item.done`, and +`response.completed`. Responses streams do not send a Chat Completions-style `[DONE]` sentinel. + +## Structured Output + +Non-streaming JSON Schema output: + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "input": "Extract the city and weather from: Hangzhou is hot today.", + "text": { + "format": { + "type": "json_schema", + "name": "weather_extract", + "strict": true, + "schema": { + "type": "object", + "properties": { + "city": { "type": "string" }, + "weather": { "type": "string" } + }, + "required": ["city", "weather"], + "additionalProperties": false + } + } + } + }' | tee /tmp/schema-response.json | python3 -m json.tool +``` + +Check that `output_text` is valid JSON: + +```bash +python3 - <<'PY' +import json +r = json.load(open("/tmp/schema-response.json")) +print("status =", r.get("status")) +print("output_text parsed =", json.loads(r["output_text"])) +PY +``` + +Streaming JSON Schema output: + +```bash +curl -N -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -H 'Accept: text/event-stream' \ + -d '{ + "model": "qwen3-max", + "stream": true, + "input": "Extract the city and weather from: Hangzhou is hot today.", + "text": { + "format": { + "type": "json_schema", + "name": "weather_extract", + "strict": true, + "schema": { + "type": "object", + "properties": { + "city": { "type": "string" }, + "weather": { "type": "string" } + }, + "required": ["city", "weather"], + "additionalProperties": false + } + } + } + }' +``` + +Expected result: the stream reaches `response.completed`, and the text delta/done payload contains a +JSON object. + +## Background And State + +Create a background response: + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "background": true, + "store": true, + "input": "Write a longer AgentScope Java overview." + }' | tee /tmp/background-response.json | python3 -m json.tool +``` + +Retrieve and cancel it: + +```bash +BG_ID=$(python3 -c 'import json; print(json.load(open("/tmp/background-response.json"))["id"])') +curl -s "http://localhost:8080/v1/responses/${BG_ID}" | python3 -m json.tool +curl -s -X POST "http://localhost:8080/v1/responses/${BG_ID}/cancel" | python3 -m json.tool +curl -s -X DELETE "http://localhost:8080/v1/responses/${BG_ID}" | python3 -m json.tool +``` + +Expected result: the initial response is `queued`; cancel returns the same response with +`status=cancelled`; delete returns `deleted=true`. + +List the input items for a stored response: + +```bash +curl -s "http://localhost:8080/v1/responses/${RESP_ID}/input_items?limit=10&order=asc" \ + | python3 -m json.tool +``` + +Count input tokens: + +```bash +curl -s -X POST http://localhost:8080/v1/responses/input_tokens \ + -H 'Content-Type: application/json' \ + -d '{"input":"hello AgentScope"}' | python3 -m json.tool +``` + +Compact context: + +```bash +curl -s -X POST http://localhost:8080/v1/responses/compact \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "input": "Summarize this context in one sentence: AgentScope Java supports agents, tools, streaming, and structured output." + }' | python3 -m json.tool +``` + +## Conversations + +Create a conversation: + +```bash +curl -s -X POST http://localhost:8080/v1/conversations \ + -H 'Content-Type: application/json' \ + -d '{"metadata":{"case":"manual-test"}}' \ + | tee /tmp/conversation.json | python3 -m json.tool +``` + +Use the conversation in a response: + +```bash +CONV_ID=$(python3 -c 'import json; print(json.load(open("/tmp/conversation.json"))["id"])') +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d "{ + \"model\": \"qwen3-max\", + \"conversation\": \"${CONV_ID}\", + \"input\": \"Add this message to the conversation.\", + \"store\": true + }" | python3 -m json.tool +``` + +List conversation items: + +```bash +curl -s "http://localhost:8080/v1/conversations/${CONV_ID}/items?limit=10&order=asc" \ + | python3 -m json.tool +``` + +Create, retrieve, and delete a conversation item: + +```bash +curl -s -X POST "http://localhost:8080/v1/conversations/${CONV_ID}/items" \ + -H 'Content-Type: application/json' \ + -d '{ + "items": [{ + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "Hello from a conversation item." }] + }] + }' | tee /tmp/conversation-items.json | python3 -m json.tool + +ITEM_ID=$(python3 -c 'import json; print(json.load(open("/tmp/conversation-items.json"))["data"][0]["id"])') +curl -s "http://localhost:8080/v1/conversations/${CONV_ID}/items/${ITEM_ID}" \ + | python3 -m json.tool +curl -s -X DELETE "http://localhost:8080/v1/conversations/${CONV_ID}/items/${ITEM_ID}" \ + | python3 -m json.tool +``` + +## Tools And Multimodal Inputs + +Request-level function tools are registered as schema-only tools. This follows the client-side tool +loop pattern: the model may return a `function_call`, the client executes it, and the next request +sends a `function_call_output`. + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "input": "Call get_weather for Hangzhou, then wait for the tool result.", + "tools": [{ + "type": "function", + "name": "get_weather", + "description": "Get the current weather for a city", + "parameters": { + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"], + "additionalProperties": false + }, + "strict": true + }], + "tool_choice": { "type": "function", "name": "get_weather" }, + "store": true + }' | tee /tmp/tool-call-response.json | python3 -m json.tool +``` + +Backend Java tools are different: register real Java methods in the application `Toolkit` with +`@Tool`, then let `ReActAgent` execute them. When using backend Java tools, avoid sending a request +`tools` entry with the same name because request-level schemas are external schema-only tools. + +```java +public class WeatherTools { + @Tool(name = "get_weather", description = "Get weather for a city") + public String getWeather(@ToolParam(name = "city", description = "City name") String city) { + return city + " is sunny, 28C"; + } +} +``` + +Image and audio inputs are converted into AgentScope multimodal content blocks. End-to-end +understanding still depends on the selected model and model adapter. If a text-only model says it +cannot view images or process audio, the API layer still accepted the request, but the model backend +does not provide multimodal understanding. + +File inputs with `file_id` are accepted as file references. To understand file content in +production, the application should provide upload, storage, authorization, parsing, and content +injection before calling the model. + +## Error Checks + +Unsupported text format: + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "input": "hello", + "text": { "format": { "type": "json_object" } } + }' | python3 -m json.tool +``` + +Expected result: a structured error response. + +Missing stored response: + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "previous_response_id": "resp_not_found", + "input": "hello" + }' | python3 -m json.tool +``` + +Expected result: a structured not-found error, not an unsupported-parameter error. + +## Verification + +```bash +mvn -pl agentscope-extensions/agentscope-extensions-responses-web -am \ + -Dtest='io.agentscope.core.responses.**' \ + -DfailIfNoTests=false -DfailIfNoSpecifiedTests=false test + +mvn -pl agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter -am \ + -Dtest='io.agentscope.spring.boot.responses.**' \ + -DfailIfNoTests=false -DfailIfNoSpecifiedTests=false test + +mvn -pl agentscope-examples/responses-web -am -DskipTests package +``` diff --git a/docs/zh/quickstart/installation.md b/docs/zh/quickstart/installation.md index bd998239a..75ecd6bff 100644 --- a/docs/zh/quickstart/installation.md +++ b/docs/zh/quickstart/installation.md @@ -230,6 +230,7 @@ implementation 'io.agentscope:agentscope-core:1.0.12' | agentscope-a2a-spring-boot-starter | A2A 集成 | `io.agentscope:agentscope-a2a-spring-boot-starter` | | agentscope-agui-spring-boot-starter | AG-UI 集成 | `io.agentscope:agentscope-agui-spring-boot-starter` | | agentscope-chat-completions-web-starter | Chat Completions Web 集成 | `io.agentscope:agentscope-chat-completions-web-starter` | +| agentscope-responses-web-starter | Responses Web 集成 | `io.agentscope:agentscope-responses-web-starter` | | agentscope-nacos-spring-boot-starter | Nacos 集成 | `io.agentscope:agentscope-nacos-spring-boot-starter` | ### Quarkus diff --git a/docs/zh/task/responses-api.md b/docs/zh/task/responses-api.md new file mode 100644 index 000000000..daffca37e --- /dev/null +++ b/docs/zh/task/responses-api.md @@ -0,0 +1,458 @@ +# Responses API + +AgentScope Java 可以通过 `agentscope-responses-web-starter` 暴露兼容 OpenAI Responses API 的 +HTTP 接口。该 starter 是增量能力,不改变现有 Chat Completions starter。 + +官方参考: + +- https://developers.openai.com/api/reference/resources/responses +- https://developers.openai.com/api/reference/responses/overview +- https://developers.openai.com/api/reference/resources/responses/streaming-events +- https://developers.openai.com/api/reference/resources/conversations +- https://developers.openai.com/api/reference/resources/conversations/subresources/items + +## 范围 + +starter 提供: + +- Responses create/retrieve/delete/cancel/input-items/token-count/compact endpoints +- Conversations create/retrieve/update/delete endpoints +- Conversation items create/list/retrieve/delete endpoints +- 非流式 JSON 响应 +- `stream=true` 时的 Responses 风格 Server-Sent Events +- 非流式和流式 JSON Schema structured output +- `previous_response_id`、`conversation`、`store`、`background` 状态化行为 +- text、image、file reference、audio、video、function call、opaque official item 处理 +- 用于外部 client-side tool loop 的 function tool schema 注册 +- 对 web search、file search、code interpreter、MCP、computer use、image generation、custom + tools 等 hosted tool 请求形状做 API 兼容接收 + +默认状态后端是内存实现。如果需要跨进程重启或多实例共享状态,请在业务应用里替换 +`ResponsesStateService` bean。 + +Hosted OpenAI tools 在 request/DTO 层会被接收,但默认 starter 不会自动执行 OpenAI hosted +services。如果需要真实执行,请把这些工具接入 AgentScope 或业务应用自己的 toolkit。 + +## 依赖 + +```xml + + + io.agentscope + agentscope-spring-boot-starter + + + io.agentscope + agentscope-responses-web-starter + ${agentscope.version} + + +``` + +## 配置 + +```yaml +agentscope: + model: + provider: dashscope + dashscope: + enabled: true + api-key: ${DASHSCOPE_API_KEY} + model-name: qwen3-max + stream: true + agent: + enabled: true + name: "ResponsesAgent" + sys-prompt: "You are a helpful assistant." + max-iters: 8 + responses: + enabled: true + base-path: /v1/responses + conversations: + base-path: /v1/conversations +``` + +该 starter 依赖 `agentscope-spring-boot-starter` 提供 `ReActAgent` bean。每次请求通过 +`ObjectProvider` 获取 fresh agent,response/conversation 状态由 +`ResponsesStateService` 管理。 + +## Endpoints + +Responses: + +- `POST /v1/responses` +- `GET /v1/responses/{response_id}` +- `DELETE /v1/responses/{response_id}` +- `POST /v1/responses/{response_id}/cancel` +- `GET /v1/responses/{response_id}/input_items` +- `POST /v1/responses/input_tokens` +- `POST /v1/responses/input_tokens/count` +- `POST /v1/responses/compact` + +Conversations: + +- `POST /v1/conversations` +- `GET /v1/conversations/{conversation_id}` +- `POST /v1/conversations/{conversation_id}` +- `DELETE /v1/conversations/{conversation_id}` +- `GET /v1/conversations/{conversation_id}/items` +- `POST /v1/conversations/{conversation_id}/items` +- `GET /v1/conversations/{conversation_id}/items/{item_id}` +- `DELETE /v1/conversations/{conversation_id}/items/{item_id}` + +列表 endpoint 支持 `after`、`limit`、`order=asc|desc`。 + +## 运行示例 + +启动示例应用: + +```bash +export DASHSCOPE_API_KEY=your_key +mvn -pl agentscope-examples/responses-web -am -DskipTests package +java -jar agentscope-examples/responses-web/target/responses-web-1.1.0-SNAPSHOT.jar \ + --server.port=8080 +``` + +如果用 IntelliJ IDEA 启动,在 Run Configuration 里配置 `DASHSCOPE_API_KEY`,并把 +`--server.port=8080` 放到 program arguments。 + +下面示例不依赖 `jq`。可以用 `python3 -m json.tool` 格式化 JSON: + +```bash +curl -s http://localhost:8080/v1/responses/resp_xxx | python3 -m json.tool +``` + +从保存的 JSON 文件里提取 ID: + +```bash +python3 -c 'import json; print(json.load(open("/tmp/response.json"))["id"])' +``` + +## 核心示例 + +纯文本: + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "input": "Briefly introduce AgentScope Java.", + "store": true + }' | tee /tmp/response.json | python3 -m json.tool +``` + +正常结果: + +- `object` 是 `response` +- `status` 是 `completed` +- `id` 以 `resp_` 开头 +- 返回里有 `output` 和 `output_text` +- 顶层没有 `error` + +查询 stored response: + +```bash +RESP_ID=$(python3 -c 'import json; print(json.load(open("/tmp/response.json"))["id"])') +curl -s "http://localhost:8080/v1/responses/${RESP_ID}" | python3 -m json.tool +``` + +用 `previous_response_id` 接续上一轮: + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d "{ + \"model\": \"qwen3-max\", + \"previous_response_id\": \"${RESP_ID}\", + \"input\": \"Continue from the previous response in one sentence.\" + }" | python3 -m json.tool +``` + +流式: + +```bash +curl -N -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -H 'Accept: text/event-stream' \ + -d '{ + "model": "qwen3-max", + "stream": true, + "input": "Describe AgentScope Java in three short sentences." + }' +``` + +流式响应使用 Responses 风格 SSE events。健康的流通常包含 `response.created`、 +`response.in_progress`、`response.output_item.added`、`response.output_text.delta`、 +`response.output_text.done`、`response.output_item.done`、`response.completed` 等事件。 +Responses stream 不发送 Chat Completions 风格的 `[DONE]`。 + +## Structured Output + +非流式 JSON Schema output: + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "input": "Extract the city and weather from: Hangzhou is hot today.", + "text": { + "format": { + "type": "json_schema", + "name": "weather_extract", + "strict": true, + "schema": { + "type": "object", + "properties": { + "city": { "type": "string" }, + "weather": { "type": "string" } + }, + "required": ["city", "weather"], + "additionalProperties": false + } + } + } + }' | tee /tmp/schema-response.json | python3 -m json.tool +``` + +检查 `output_text` 是否为合法 JSON: + +```bash +python3 - <<'PY' +import json +r = json.load(open("/tmp/schema-response.json")) +print("status =", r.get("status")) +print("output_text parsed =", json.loads(r["output_text"])) +PY +``` + +流式 JSON Schema output: + +```bash +curl -N -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -H 'Accept: text/event-stream' \ + -d '{ + "model": "qwen3-max", + "stream": true, + "input": "Extract the city and weather from: Hangzhou is hot today.", + "text": { + "format": { + "type": "json_schema", + "name": "weather_extract", + "strict": true, + "schema": { + "type": "object", + "properties": { + "city": { "type": "string" }, + "weather": { "type": "string" } + }, + "required": ["city", "weather"], + "additionalProperties": false + } + } + } + }' +``` + +正常结果:stream 能到达 `response.completed`,text delta/done 中包含 JSON object。 + +## Background And State + +创建 background response: + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "background": true, + "store": true, + "input": "Write a longer AgentScope Java overview." + }' | tee /tmp/background-response.json | python3 -m json.tool +``` + +查询、取消和删除: + +```bash +BG_ID=$(python3 -c 'import json; print(json.load(open("/tmp/background-response.json"))["id"])') +curl -s "http://localhost:8080/v1/responses/${BG_ID}" | python3 -m json.tool +curl -s -X POST "http://localhost:8080/v1/responses/${BG_ID}/cancel" | python3 -m json.tool +curl -s -X DELETE "http://localhost:8080/v1/responses/${BG_ID}" | python3 -m json.tool +``` + +正常结果:初始 response 是 `queued`;cancel 返回同一个 response 且 `status=cancelled`; +delete 返回 `deleted=true`。 + +列出 stored response 的 input items: + +```bash +curl -s "http://localhost:8080/v1/responses/${RESP_ID}/input_items?limit=10&order=asc" \ + | python3 -m json.tool +``` + +统计 input tokens: + +```bash +curl -s -X POST http://localhost:8080/v1/responses/input_tokens \ + -H 'Content-Type: application/json' \ + -d '{"input":"hello AgentScope"}' | python3 -m json.tool +``` + +压缩上下文: + +```bash +curl -s -X POST http://localhost:8080/v1/responses/compact \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "input": "Summarize this context in one sentence: AgentScope Java supports agents, tools, streaming, and structured output." + }' | python3 -m json.tool +``` + +## Conversations + +创建 conversation: + +```bash +curl -s -X POST http://localhost:8080/v1/conversations \ + -H 'Content-Type: application/json' \ + -d '{"metadata":{"case":"manual-test"}}' \ + | tee /tmp/conversation.json | python3 -m json.tool +``` + +在 response 中使用 conversation: + +```bash +CONV_ID=$(python3 -c 'import json; print(json.load(open("/tmp/conversation.json"))["id"])') +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d "{ + \"model\": \"qwen3-max\", + \"conversation\": \"${CONV_ID}\", + \"input\": \"Add this message to the conversation.\", + \"store\": true + }" | python3 -m json.tool +``` + +列出 conversation items: + +```bash +curl -s "http://localhost:8080/v1/conversations/${CONV_ID}/items?limit=10&order=asc" \ + | python3 -m json.tool +``` + +创建、查询和删除 conversation item: + +```bash +curl -s -X POST "http://localhost:8080/v1/conversations/${CONV_ID}/items" \ + -H 'Content-Type: application/json' \ + -d '{ + "items": [{ + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "Hello from a conversation item." }] + }] + }' | tee /tmp/conversation-items.json | python3 -m json.tool + +ITEM_ID=$(python3 -c 'import json; print(json.load(open("/tmp/conversation-items.json"))["data"][0]["id"])') +curl -s "http://localhost:8080/v1/conversations/${CONV_ID}/items/${ITEM_ID}" \ + | python3 -m json.tool +curl -s -X DELETE "http://localhost:8080/v1/conversations/${CONV_ID}/items/${ITEM_ID}" \ + | python3 -m json.tool +``` + +## Tools And Multimodal Inputs + +request 里的 function tools 会注册为 schema-only tools。这是 client-side tool loop 模式:模型可能 +返回 `function_call`,客户端执行工具,下一次请求再发送 `function_call_output`。 + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "input": "Call get_weather for Hangzhou, then wait for the tool result.", + "tools": [{ + "type": "function", + "name": "get_weather", + "description": "Get the current weather for a city", + "parameters": { + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"], + "additionalProperties": false + }, + "strict": true + }], + "tool_choice": { "type": "function", "name": "get_weather" }, + "store": true + }' | tee /tmp/tool-call-response.json | python3 -m json.tool +``` + +后端 Java tools 是另一种模式:业务应用用 `@Tool` 把真实 Java 方法注册到 `Toolkit`,然后让 +`ReActAgent` 自动执行。使用后端 Java tools 时,不要在请求里发送同名 `tools`,因为 request-level +schema 是外部 schema-only tool。 + +```java +public class WeatherTools { + @Tool(name = "get_weather", description = "Get weather for a city") + public String getWeather(@ToolParam(name = "city", description = "City name") String city) { + return city + " is sunny, 28C"; + } +} +``` + +image 和 audio 输入会转换成 AgentScope multimodal content blocks。端到端理解仍然取决于所选模型 +和模型适配器。如果文本模型回复“无法查看图片”或“无法处理音频”,说明 API 层接收成功,但模型后端 +没有提供多模态理解能力。 + +`file_id` 文件输入会作为文件引用被接收。生产环境如果要理解文件内容,业务应用需要提供上传、存储、 +鉴权、解析,并在调用模型前把解析后的内容注入上下文。 + +## 错误检查 + +不支持的 text format: + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "input": "hello", + "text": { "format": { "type": "json_object" } } + }' | python3 -m json.tool +``` + +正常结果:返回结构化 error。 + +不存在的 stored response: + +```bash +curl -s -X POST http://localhost:8080/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "qwen3-max", + "previous_response_id": "resp_not_found", + "input": "hello" + }' | python3 -m json.tool +``` + +正常结果:返回结构化 not-found error,而不是 unsupported-parameter error。 + +## 验证 + +```bash +mvn -pl agentscope-extensions/agentscope-extensions-responses-web -am \ + -Dtest='io.agentscope.core.responses.**' \ + -DfailIfNoTests=false -DfailIfNoSpecifiedTests=false test + +mvn -pl agentscope-extensions/agentscope-spring-boot-starters/agentscope-responses-web-starter -am \ + -Dtest='io.agentscope.spring.boot.responses.**' \ + -DfailIfNoTests=false -DfailIfNoSpecifiedTests=false test + +mvn -pl agentscope-examples/responses-web -am -DskipTests package +```