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() {