diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java
index 502a88f1c6..8ab972664d 100644
--- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java
+++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java
@@ -102,6 +102,8 @@ public OllamaChatModel(
defaultOptions != null ? defaultOptions : OllamaOptions.builder().build();
this.formatter = formatter != null ? formatter : new OllamaChatFormatter();
this.stream = stream;
+ setNativeStructuredOutput(true);
+ setNativeStructuredOutputWithTools(false);
HttpTransport transport =
httpTransport != null ? httpTransport : HttpTransportFactory.getDefault();
@@ -272,6 +274,8 @@ public static class Builder {
private ProxyConfig proxyConfig;
private int contextWindowSize = -1;
private boolean stream = true;
+ private boolean nativeStructuredOutput = true;
+ private boolean nativeStructuredOutputWithTools = false;
public Builder modelName(String modelName) {
this.modelName = modelName;
@@ -364,6 +368,37 @@ public Builder contextWindowSize(int contextWindowSize) {
return this;
}
+ /**
+ * Sets whether this model supports native structured output via Ollama's
+ * {@code format} field.
+ *
+ *
Defaults to {@code true}. Set to {@code false} for endpoints that do not
+ * support JSON Schema-constrained output; the agent will use the synthetic
+ * {@code generate_response} tool instead.
+ *
+ * @param nativeStructuredOutput false to disable native structured output
+ * @return this builder instance
+ */
+ public Builder nativeStructuredOutput(boolean nativeStructuredOutput) {
+ this.nativeStructuredOutput = nativeStructuredOutput;
+ return this;
+ }
+
+ /**
+ * Sets whether this model handles native structured output alongside tool calling.
+ *
+ *
Defaults to {@code false} because Ollama does not document guaranteed behavior
+ * for combining {@code format} and {@code tools}. Set to {@code true} for models and
+ * endpoints where that combination has been verified.
+ *
+ * @param nativeStructuredOutputWithTools true to use native output with tools
+ * @return this builder instance
+ */
+ public Builder nativeStructuredOutputWithTools(boolean nativeStructuredOutputWithTools) {
+ this.nativeStructuredOutputWithTools = nativeStructuredOutputWithTools;
+ return this;
+ }
+
public OllamaChatModel build() {
OllamaOptions finalOptions =
defaultOptions != null
@@ -381,6 +416,8 @@ public OllamaChatModel build() {
OllamaChatModel model =
new OllamaChatModel(
modelName, baseUrl, finalOptions, formatter, transport, stream);
+ model.setNativeStructuredOutput(nativeStructuredOutput);
+ model.setNativeStructuredOutputWithTools(nativeStructuredOutputWithTools);
if (contextWindowSize >= 0) {
model.setContextWindowSize(contextWindowSize);
}
diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/options/OllamaOptions.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/options/OllamaOptions.java
index 1c94912599..2db650d375 100644
--- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/options/OllamaOptions.java
+++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/options/OllamaOptions.java
@@ -19,6 +19,7 @@
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
+import io.agentscope.core.formatter.ResponseFormat;
import io.agentscope.core.model.ExecutionConfig;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.util.JsonUtils;
@@ -392,6 +393,16 @@ public static OllamaOptions fromGenerateOptions(GenerateOptions genOptions) {
builder.thinkOption(ThinkOption.ThinkBoolean.ENABLED);
}
+ ResponseFormat responseFormat = genOptions.getResponseFormat();
+ if (responseFormat != null) {
+ if ("json_schema".equals(responseFormat.getType())) {
+ // Ollama expects the schema itself, without the OpenAI response-format wrapper.
+ builder.format(responseFormat.getJsonSchema().getSchema());
+ } else if ("json_object".equals(responseFormat.getType())) {
+ builder.format("json");
+ }
+ }
+
// Map additional parameters from map
if (genOptions.getAdditionalBodyParams() != null) {
applyAdditionalParams(builder, genOptions.getAdditionalBodyParams());
diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java
new file mode 100644
index 0000000000..cd2dcbbe1d
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java
@@ -0,0 +1,288 @@
+/*
+ * 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
+ *
+ * https://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.extensions.model.ollama;
+
+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.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import io.agentscope.core.ReActAgent;
+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.ThinkingBlock;
+import io.agentscope.core.model.transport.HttpRequest;
+import io.agentscope.core.model.transport.HttpResponse;
+import io.agentscope.core.model.transport.HttpTransport;
+import io.agentscope.core.tool.Tool;
+import io.agentscope.core.tool.Toolkit;
+import io.agentscope.core.util.JsonUtils;
+import io.agentscope.extensions.model.ollama.options.OllamaOptions;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import reactor.core.publisher.Flux;
+
+class OllamaStructuredOutputTest {
+ private static final String JSON = "{\"description\":[\"a red apple\"],\"name\":\"apple\"}";
+ private static final String IMAGE =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aG1kAAAAASUVORK5CYII=";
+
+ public static class ImageDescription {
+ public List description;
+ public String name;
+ }
+
+ public static class Tools {
+ @Tool(name = "describe", description = "Describe an image")
+ public String describe() {
+ return "a red apple";
+ }
+ }
+
+ @Test
+ void builderExposesNativeStructuredOutputConfiguration() {
+ HttpTransport transport = mock(HttpTransport.class);
+ OllamaChatModel model =
+ OllamaChatModel.builder().modelName("qwen3.5:9b").httpTransport(transport).build();
+ assertTrue(model.supportsNativeStructuredOutput());
+ assertFalse(model.supportsNativeStructuredOutputWithTools());
+
+ OllamaChatModel configured =
+ OllamaChatModel.builder()
+ .modelName("qwen3.5:9b")
+ .httpTransport(transport)
+ .nativeStructuredOutput(false)
+ .nativeStructuredOutputWithTools(true)
+ .build();
+ assertFalse(configured.supportsNativeStructuredOutput());
+ assertTrue(configured.supportsNativeStructuredOutputWithTools());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void structuredImageResponsePreservesJsonAndFinalUsage(boolean streaming) {
+ HttpTransport transport = mock(HttpTransport.class);
+ if (streaming) {
+ when(transport.stream(any(HttpRequest.class)))
+ .thenReturn(
+ Flux.just(
+ frame(
+ Map.of(
+ "role",
+ "assistant",
+ "thinking",
+ "Inspecting the image."),
+ false),
+ frame(
+ Map.of(
+ "role",
+ "assistant",
+ "content",
+ JSON.substring(0, 23)),
+ false),
+ frame(
+ Map.of(
+ "role",
+ "assistant",
+ "content",
+ JSON.substring(23)),
+ false),
+ frame(Map.of("role", "assistant", "content", ""), true)));
+ } else {
+ when(transport.execute(any(HttpRequest.class)))
+ .thenReturn(
+ HttpResponse.builder()
+ .statusCode(200)
+ .body(frame(Map.of("role", "assistant", "content", JSON), true))
+ .build());
+ }
+
+ OllamaChatModel model =
+ OllamaChatModel.builder().modelName("qwen3.5:9b").stream(streaming)
+ .httpTransport(transport)
+ .defaultOptions(OllamaOptions.builder().format("json").build())
+ .build();
+ Msg input =
+ Msg.builder()
+ .role(MsgRole.USER)
+ .content(
+ List.of(
+ TextBlock.builder().text("Describe this image").build(),
+ ImageBlock.builder()
+ .source(
+ Base64Source.builder()
+ .mediaType("image/png")
+ .data(IMAGE)
+ .build())
+ .build()))
+ .build();
+ try (ReActAgent agent = ReActAgent.builder().name("vision").model(model).build()) {
+ Msg result =
+ agent.call(List.of(input), ImageDescription.class)
+ .block(Duration.ofSeconds(10));
+ assertNotNull(result);
+ assertEquals("apple", result.getStructuredData(ImageDescription.class).name);
+ assertEquals(JSON, result.getTextContent());
+ assertNotNull(result.getUsage());
+ assertEquals(23, result.getUsage().getInputTokens());
+ assertEquals(12, result.getUsage().getOutputTokens());
+ if (streaming) {
+ assertEquals(
+ "Inspecting the image.",
+ result.getContentBlocks(ThinkingBlock.class).get(0).getThinking());
+ }
+ }
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class);
+ if (streaming) {
+ verify(transport).stream(captor.capture());
+ } else {
+ verify(transport).execute(captor.capture());
+ }
+ JsonNode request =
+ JsonUtils.getJsonCodec().fromJson(captor.getValue().getBody(), JsonNode.class);
+ assertEquals("object", request.path("format").path("type").asText());
+ assertTrue(request.path("format").path("properties").has("name"));
+ assertTrue(request.path("format").path("properties").has("description"));
+ assertFalse(request.path("format").has("json_schema"));
+ assertFalse(request.has("tools"));
+ assertTrue(
+ request.path("messages").findValues("images").stream()
+ .anyMatch(images -> images.get(0).asText().equals(IMAGE)));
+ }
+
+ @Test
+ void businessToolsDefaultToSyntheticStructuredOutput() {
+ HttpTransport transport = mock(HttpTransport.class);
+ Map call =
+ Map.of(
+ "function",
+ Map.of(
+ "name",
+ "generate_response",
+ "arguments",
+ Map.of(
+ "response",
+ Map.of(
+ "name",
+ "apple",
+ "description",
+ List.of("a red apple")))));
+ when(transport.execute(any(HttpRequest.class)))
+ .thenReturn(
+ HttpResponse.builder()
+ .statusCode(200)
+ .body(
+ frame(
+ Map.of(
+ "role",
+ "assistant",
+ "content",
+ "",
+ "tool_calls",
+ List.of(call)),
+ true))
+ .build());
+ Toolkit toolkit = new Toolkit();
+ toolkit.registerTool(new Tools());
+ OllamaChatModel model =
+ OllamaChatModel.builder().modelName("qwen3.5:9b").stream(false)
+ .httpTransport(transport)
+ .build();
+ try (ReActAgent agent =
+ ReActAgent.builder().name("tools").model(model).toolkit(toolkit).build()) {
+ Msg result =
+ agent.call("Describe an image", ImageDescription.class)
+ .block(Duration.ofSeconds(10));
+ assertNotNull(result);
+ assertEquals("apple", result.getStructuredData(ImageDescription.class).name);
+ assertEquals(12, result.getUsage().getOutputTokens());
+ }
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class);
+ verify(transport).execute(captor.capture());
+ JsonNode request =
+ JsonUtils.getJsonCodec().fromJson(captor.getValue().getBody(), JsonNode.class);
+ assertFalse(request.has("format"));
+ List names = request.path("tools").findValues("name");
+ assertTrue(names.stream().anyMatch(name -> "generate_response".equals(name.asText())));
+ assertTrue(names.stream().anyMatch(name -> "describe".equals(name.asText())));
+ }
+
+ @Test
+ void businessToolsCanOptIntoNativeStructuredOutput() {
+ HttpTransport transport = mock(HttpTransport.class);
+ when(transport.execute(any(HttpRequest.class)))
+ .thenReturn(
+ HttpResponse.builder()
+ .statusCode(200)
+ .body(frame(Map.of("role", "assistant", "content", JSON), true))
+ .build());
+ Toolkit toolkit = new Toolkit();
+ toolkit.registerTool(new Tools());
+ OllamaChatModel model =
+ OllamaChatModel.builder().modelName("qwen3.5:9b").stream(false)
+ .httpTransport(transport)
+ .nativeStructuredOutputWithTools(true)
+ .build();
+ try (ReActAgent agent =
+ ReActAgent.builder().name("tools").model(model).toolkit(toolkit).build()) {
+ Msg result =
+ agent.call("Describe an image", ImageDescription.class)
+ .block(Duration.ofSeconds(10));
+ assertNotNull(result);
+ assertEquals("apple", result.getStructuredData(ImageDescription.class).name);
+ }
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class);
+ verify(transport).execute(captor.capture());
+ JsonNode request =
+ JsonUtils.getJsonCodec().fromJson(captor.getValue().getBody(), JsonNode.class);
+ assertEquals("object", request.path("format").path("type").asText());
+ List names = request.path("tools").findValues("name");
+ assertTrue(names.stream().anyMatch(name -> "describe".equals(name.asText())));
+ assertFalse(names.stream().anyMatch(name -> "generate_response".equals(name.asText())));
+ }
+
+ private static String frame(Map message, boolean done) {
+ return JsonUtils.getJsonCodec()
+ .toJson(
+ Map.of(
+ "model",
+ "qwen3.5:9b",
+ "message",
+ message,
+ "done",
+ done,
+ "prompt_eval_count",
+ done ? 23 : 0,
+ "eval_count",
+ done ? 12 : 0));
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/options/OllamaOptionsTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/options/OllamaOptionsTest.java
index 1057458ea2..27a8279c18 100644
--- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/options/OllamaOptionsTest.java
+++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/options/OllamaOptionsTest.java
@@ -22,6 +22,8 @@
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import io.agentscope.core.formatter.JsonSchema;
+import io.agentscope.core.formatter.ResponseFormat;
import io.agentscope.core.model.ExecutionConfig;
import io.agentscope.core.model.GenerateOptions;
import java.time.Duration;
@@ -108,6 +110,38 @@ void testFromGenerateOptions() {
assertEquals(Integer.valueOf(123), options.getSeed());
}
+ @Test
+ void testJsonSchemaResponseFormat() {
+ Map schema =
+ Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string")));
+ GenerateOptions options =
+ GenerateOptions.builder()
+ .responseFormat(
+ ResponseFormat.jsonSchema(
+ JsonSchema.builder()
+ .name("result")
+ .schema(schema)
+ .strict(true)
+ .build()))
+ .build();
+
+ assertEquals(schema, OllamaOptions.fromGenerateOptions(options).getFormat());
+ }
+
+ @Test
+ void testJsonObjectResponseFormat() {
+ GenerateOptions options =
+ GenerateOptions.builder().responseFormat(ResponseFormat.jsonObject()).build();
+ assertEquals("json", OllamaOptions.fromGenerateOptions(options).getFormat());
+ }
+
+ @Test
+ void testTextResponseFormat() {
+ GenerateOptions options =
+ GenerateOptions.builder().responseFormat(ResponseFormat.text()).build();
+ assertNull(OllamaOptions.fromGenerateOptions(options).getFormat());
+ }
+
@Test
@DisplayName("Should handle null GenerateOptions in fromGenerateOptions")
void testFromNullGenerateOptions() {