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:
+ *
+ *
+ * Clients create or retrieve a conversation resource
+ * Clients add, list, retrieve, or delete conversation items
+ * {@code POST /v1/responses} can reference the conversation ID to include prior items as
+ * input context
+ * 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:
+ *
+ *
+ * Client sends {@code input}, optional {@code instructions}, optional tools, and optional
+ * output formatting
+ * Controller converts the HTTP DTO into AgentScope messages and request-scoped options
+ * Server creates a fresh {@link ReActAgent}, registers schema-only tools when requested, and
+ * attaches per-request options
+ * Agent runs in normal, structured-output, or streaming mode
+ * 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
+```