From aeb8c439eac2e5774b24a0b4fc2f4ce2a19b88b7 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Wed, 30 Sep 2026 22:09:43 +0000 Subject: [PATCH 1/6] feat(beta): bind typed application actions to agent tools --- docs/helpers/agent-sessions.md | 31 ++++++++ .../lib/beta/agents/AgentFunctionTool.kt | 68 ++++++++++++++++++ .../lib/beta/agents/AgentFunctionToolTest.kt | 71 +++++++++++++++++++ .../beta/agents/AgentSessionStreamTest.kt | 57 +++++++++++++++ .../openai/example/BetaAgentToolsExample.java | 63 ++++++++++++++++ 5 files changed, 290 insertions(+) create mode 100644 openai-java-core/src/main/kotlin/com/openai/lib/beta/agents/AgentFunctionTool.kt create mode 100644 openai-java-core/src/test/kotlin/com/openai/lib/beta/agents/AgentFunctionToolTest.kt create mode 100644 openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java diff --git a/docs/helpers/agent-sessions.md b/docs/helpers/agent-sessions.md index 67905f694..e76b4b4b0 100644 --- a/docs/helpers/agent-sessions.md +++ b/docs/helpers/agent-sessions.md @@ -76,3 +76,34 @@ String text = AgentSessionMessages.outputText(message); The raw `sessions().events().streamStreaming(...)` API remains available for following active sessions and advanced orchestration. + +## Typed application tools (beta) + +Bind an argument class and application callback once. Jackson's `@JsonTypeName` +and `@JsonClassDescription` set the hosted name and description, as with other +class-based SDK tools. + +```java +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.openai.lib.beta.agents.AgentFunctionTool; + +@JsonTypeName("lookup_order") +@JsonClassDescription("Look up an order.") +class LookupOrder { public String orderId; } + +var lookup = AgentFunctionTool.of(LookupOrder.class, args -> orderService.lookup(args.orderId)); +// Include lookup.definition() in the agent's tools when configuring the session. +var params = AgentSessionStreamParams.builder() + .sessionId(sessionId) + .input("Where is order A123?") + .toolHandler(lookup.name(), lookup.handler()) + .build(); +``` + +For callbacks returning a `CompletionStage`, use `AgentFunctionTool.ofAsync(...)` +and register with `.asyncToolHandler(tool.name(), tool.handler())`. Attach either +binding to an existing session using its hosted tool name. Application services +and credentials stay in the closure or bound method. See +[`BetaAgentToolsExample`](../../openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java) +for a read-only wallet action. diff --git a/openai-java-core/src/main/kotlin/com/openai/lib/beta/agents/AgentFunctionTool.kt b/openai-java-core/src/main/kotlin/com/openai/lib/beta/agents/AgentFunctionTool.kt new file mode 100644 index 000000000..0ff1a5dde --- /dev/null +++ b/openai-java-core/src/main/kotlin/com/openai/lib/beta/agents/AgentFunctionTool.kt @@ -0,0 +1,68 @@ +package com.openai.lib.beta.agents + +import com.openai.core.JsonSchemaLocalValidation +import com.openai.core.JsonValue +import com.openai.core.extractFunctionInfo +import com.openai.core.responseTypeFromJson +import com.openai.core.toJsonString +import com.openai.models.beta.agents.AgentTool +import java.util.concurrent.CompletionStage +import java.util.function.Function + +/** + * A beta Agent function definition and its local, typed application callback. Argument classes use + * the SDK's class-based schema and Jackson deserialization conventions. Bind dependencies with a + * closure or method reference; only the argument class becomes part of the hosted definition. + */ +class AgentFunctionTool +private constructor( + private val definition: AgentTool, + private val handler: Function, R>, +) { + /** Add this definition to the agent's tools when creating or updating its configuration. */ + fun definition(): AgentTool = definition + + fun name(): String = definition.asFunction().name() + + /** Register with `toolHandler`, or `asyncToolHandler` for a binding created by [ofAsync]. */ + fun handler(): Function, R> = handler + + companion object { + /** + * The callback returns the same string, map, content list, or null as a raw tool handler. + */ + @JvmStatic + fun of(parametersType: Class, handler: Function): AgentFunctionTool = + bind(parametersType, Function { handler.apply(it) }) + + /** The existing stream dispatcher awaits the callback's stage before continuing. */ + @JvmStatic + fun ofAsync( + parametersType: Class, + handler: Function>, + ): AgentFunctionTool> = + bind(parametersType, Function { handler.apply(it) }) + + private fun bind( + parametersType: Class, + handler: Function, + ): AgentFunctionTool { + val info = extractFunctionInfo(parametersType, JsonSchemaLocalValidation.YES) + val definition = + AgentTool.ofFunction( + AgentTool.Function.builder() + .name(info.name) + .description(info.description ?: "") + .deferLoading(false) + .parameters(JsonValue.fromJsonNode(info.schema)) + .build() + ) + return AgentFunctionTool( + definition, + Function { arguments -> + handler.apply(responseTypeFromJson(toJsonString(arguments), parametersType)) + }, + ) + } + } +} diff --git a/openai-java-core/src/test/kotlin/com/openai/lib/beta/agents/AgentFunctionToolTest.kt b/openai-java-core/src/test/kotlin/com/openai/lib/beta/agents/AgentFunctionToolTest.kt new file mode 100644 index 000000000..469cca585 --- /dev/null +++ b/openai-java-core/src/test/kotlin/com/openai/lib/beta/agents/AgentFunctionToolTest.kt @@ -0,0 +1,71 @@ +package com.openai.lib.beta.agents + +import com.fasterxml.jackson.annotation.JsonClassDescription +import com.fasterxml.jackson.annotation.JsonTypeName +import com.fasterxml.jackson.databind.JsonNode +import com.openai.core.jsonMapper +import java.util.concurrent.CompletableFuture +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +internal class AgentFunctionToolTest { + @JsonTypeName("wallet_balance") + @JsonClassDescription("Look up a wallet balance.") + class Balance(val asset: String) + + class Lookup(val orderId: String) + + private class WalletActions(private val network: String) { + fun balance(args: Balance): Map = + mapOf("type" to "balance", "content" to args.asset, "network" to network) + } + + @Test + fun bindsApplicationActionWithoutExposingDependencies() { + val wallet = WalletActions("test-network") + val tool = AgentFunctionTool.of(Balance::class.java, wallet::balance) + val definition = jsonMapper().valueToTree(tool.definition().validate()) + assertThat(tool.name()).isEqualTo("wallet_balance") + assertThat(definition.path("type").asText()).isEqualTo("function") + assertThat(definition.path("description").asText()).isEqualTo("Look up a wallet balance.") + assertThat(definition.has("function")).isFalse() + assertThat( + definition.path("parameters").path("properties").fieldNames().asSequence().toList() + ) + .containsExactly("asset") + assertThat(definition.path("parameters").path("required").map { it.asText() }) + .containsExactly("asset") + assertThat(definition.path("parameters").path("additionalProperties").asBoolean()).isFalse() + assertThat(tool.handler().apply(mapOf("asset" to "ETH"))) + .isEqualTo(mapOf("type" to "balance", "content" to "ETH", "network" to "test-network")) + } + + @Test + fun supportsUnannotatedClassesAndAsyncStages() { + val pending = CompletableFuture() + val tool = + AgentFunctionTool.ofAsync(Lookup::class.java) { args -> + assertThat(args.orderId).isEqualTo("A123") + pending + } + assertThat(tool.name()).isEqualTo("Lookup") + tool.definition().validate() + assertThat(tool.handler().apply(mapOf("orderId" to "A123"))).isSameAs(pending) + } + + @Test + fun parsingFailsBeforeCallingApplication() { + var invocations = 0 + val tool = + AgentFunctionTool.of(Balance::class.java) { + invocations++ + "unused" + } + for (arguments in listOf(emptyMap(), mapOf("asset" to emptyList()))) { + assertThatThrownBy { tool.handler().apply(arguments) } + .hasMessageContaining("Error parsing JSON") + } + assertThat(invocations).isZero() + } +} diff --git a/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt b/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt index 1edcabea1..3954166d1 100644 --- a/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt @@ -6,6 +6,7 @@ import com.openai.core.ClientOptions import com.openai.core.RequestOptions import com.openai.core.http.* import com.openai.core.jsonMapper +import com.openai.lib.beta.agents.AgentFunctionTool import com.openai.models.beta.agents.* import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -237,6 +238,62 @@ internal class AgentSessionStreamTest { private fun params() = AgentSessionStreamParams.builder().sessionId("s").input("hello") + class WalletBalance(val asset: String) + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun typedActionsReuseTheDispatcher(async: Boolean) { + for (asyncHandler in listOf(false, true)) { + val calls = mutableListOf() + val action = { args: WalletBalance -> + calls.add(args.asset) + mapOf("type" to "balance", "content" to args.asset) + } + val p = params().toolHandler("raw") { mapOf("raw" to it["asset"]) } + if (asyncHandler) { + val tool = + AgentFunctionTool.ofAsync(WalletBalance::class.java) { + CompletableFuture.completedFuture(action(it)) + } + p.asyncToolHandler(tool.name(), tool.handler()) + } else { + val tool = AgentFunctionTool.of(WalletBalance::class.java, action) + p.toolHandler(tool.name(), tool.handler()) + } + val t = + Transport( + listOf( + turn("created"), + call(name = "WalletBalance", args = "{\"asset\":\"ETH\"}"), + call( + name = "WalletBalance", + event = "redelivered", + args = "{\"asset\":\"ETH\"}", + ), + call( + id = "legacy", + name = "WalletBalance", + args = "\"{\\\"asset\\\":\\\"BTC\\\"}\"", + ), + call(id = "invalid", name = "WalletBalance", args = "{}"), + call(id = "raw", name = "raw", args = "{\"asset\":\"USD\"}"), + turn("completed"), + idle(), + ) + ) + consume(t, async, p.build()) + assertThat(calls).containsExactly("ETH", "BTC") + val results = t.posts.drop(1).map { it.path("events").first() } + assertThat(results).hasSize(4) + assertThat(results[0].path("output").asText()) + .isEqualTo("{\"type\":\"balance\",\"content\":\"ETH\"}") + assertThat(results[1].path("success").asBoolean()).isTrue() + assertThat(results[2].path("success").asBoolean()).isFalse() + assertThat(results[2].path("error").asText()).isEqualTo("Tool handler failed.") + assertThat(results[3].path("output").asText()).isEqualTo("{\"raw\":\"USD\"}") + } + } + private fun consume( t: Transport, async: Boolean, diff --git a/openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java b/openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java new file mode 100644 index 000000000..c1f9b2582 --- /dev/null +++ b/openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java @@ -0,0 +1,63 @@ +package com.openai.example; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.openai.client.OpenAIClient; +import com.openai.lib.beta.agents.AgentFunctionTool; +import com.openai.models.beta.agents.AgentSessionStreamParams; +import com.openai.models.beta.agents.sessions.SessionCreateParams; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** Bind a read-only, Coinbase-style application action without exposing application dependencies. */ +public final class BetaAgentToolsExample { + @JsonTypeName("wallet_balance") + @JsonClassDescription("Look up a wallet balance.") + public static final class Balance { + public String asset; + } + + public static final class WalletActions { + private final String network; + + public WalletActions(String network) { + this.network = network; + } + + public Map balance(Balance args) { + // Replace this synthetic response with your application's authorized wallet lookup. + return Map.of("asset", args.asset, "network", network, "balance", "0"); + } + } + + public static SessionCreateParams.Agent agent(String model, WalletActions wallet) { + var balance = AgentFunctionTool.of(Balance.class, wallet::balance); + return SessionCreateParams.Agent.builder() + .model(model) + .addTool(balance.definition()) + .build(); + } + + public static void followUp(OpenAIClient client, String sessionId, WalletActions wallet) { + var balance = AgentFunctionTool.of(Balance.class, wallet::balance); + var params = AgentSessionStreamParams.builder() + .sessionId(sessionId) + .input("What is my ETH balance?") + .toolHandler(balance.name(), balance.handler()) + .build(); + try (var stream = client.beta().agents().sessions().stream(params)) { + stream.stream() + .forEach(event -> event.turnOutputTextDelta().ifPresent(delta -> System.out.print(delta.delta()))); + } + } + + public static AgentSessionStreamParams asyncParams(String sessionId, WalletActions wallet) { + var balance = AgentFunctionTool.ofAsync( + Balance.class, args -> CompletableFuture.completedFuture(wallet.balance(args))); + return AgentSessionStreamParams.builder() + .sessionId(sessionId) + .input("What is my ETH balance?") + .asyncToolHandler(balance.name(), balance.handler()) + .build(); + } +} From 799eee090e2f82ab2fc15790b66ccfe172f4fad4 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Wed, 30 Sep 2026 22:23:55 +0000 Subject: [PATCH 2/6] fix(beta): validate typed tool shapes and use request definitions --- docs/helpers/agent-sessions.md | 5 +- .../helpers/beta/agents/AgentFunctionTool.kt | 136 ++++++++++++++++++ .../lib/beta/agents/AgentFunctionTool.kt | 68 --------- .../beta/agents/AgentFunctionToolTest.kt | 71 ++++++++- .../beta/agents/AgentSessionStreamTest.kt | 2 +- .../openai/example/BetaAgentToolsExample.java | 2 +- 6 files changed, 210 insertions(+), 74 deletions(-) create mode 100644 openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt delete mode 100644 openai-java-core/src/main/kotlin/com/openai/lib/beta/agents/AgentFunctionTool.kt rename openai-java-core/src/test/kotlin/com/openai/{lib => helpers}/beta/agents/AgentFunctionToolTest.kt (53%) diff --git a/docs/helpers/agent-sessions.md b/docs/helpers/agent-sessions.md index e76b4b4b0..cfedd344b 100644 --- a/docs/helpers/agent-sessions.md +++ b/docs/helpers/agent-sessions.md @@ -86,7 +86,7 @@ class-based SDK tools. ```java import com.fasterxml.jackson.annotation.JsonClassDescription; import com.fasterxml.jackson.annotation.JsonTypeName; -import com.openai.lib.beta.agents.AgentFunctionTool; +import com.openai.helpers.beta.agents.AgentFunctionTool; @JsonTypeName("lookup_order") @JsonClassDescription("Look up an order.") @@ -104,6 +104,7 @@ var params = AgentSessionStreamParams.builder() For callbacks returning a `CompletionStage`, use `AgentFunctionTool.ofAsync(...)` and register with `.asyncToolHandler(tool.name(), tool.handler())`. Attach either binding to an existing session using its hosted tool name. Application services -and credentials stay in the closure or bound method. See +and credentials stay in the closure or bound method. Use strings for exact decimal +amounts, as the existing event decoder represents JSON fractions as doubles. See [`BetaAgentToolsExample`](../../openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java) for a read-only wallet action. diff --git a/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt b/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt new file mode 100644 index 000000000..62d24d3dc --- /dev/null +++ b/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt @@ -0,0 +1,136 @@ +package com.openai.helpers.beta.agents + +import com.fasterxml.jackson.databind.JsonNode +import com.openai.core.JsonSchemaLocalValidation +import com.openai.core.JsonValue +import com.openai.core.extractFunctionInfo +import com.openai.core.jsonMapper +import com.openai.core.responseTypeFromJson +import com.openai.core.toJsonString +import com.openai.models.beta.agents.AgentToolParam +import java.util.concurrent.CompletionStage +import java.util.function.Function + +/** + * A beta Agent function definition and its local, typed application callback. Argument classes use + * the SDK's class-based schema and Jackson deserialization conventions. Bind dependencies with a + * closure or method reference; only the argument class becomes part of the hosted definition. + */ +class AgentFunctionTool +private constructor( + private val definition: AgentToolParam, + private val handler: Function, R>, +) { + /** Add this definition to the agent's tools when creating or updating its configuration. */ + fun definition(): AgentToolParam = definition + + fun name(): String = definition.asFunction().name() + + /** Register with `toolHandler`, or `asyncToolHandler` for a binding created by [ofAsync]. */ + fun handler(): Function, R> = handler + + companion object { + /** + * The callback returns the same string, map, content list, or null as a raw tool handler. + */ + @JvmStatic + fun of(parametersType: Class, handler: Function): AgentFunctionTool = + bind(parametersType, Function { handler.apply(it) }) + + /** The existing stream dispatcher awaits the callback's stage before continuing. */ + @JvmStatic + fun ofAsync( + parametersType: Class, + handler: Function>, + ): AgentFunctionTool> = + bind(parametersType, Function { handler.apply(it) }) + + private fun bind( + parametersType: Class, + handler: Function, + ): AgentFunctionTool { + val info = extractFunctionInfo(parametersType, JsonSchemaLocalValidation.YES) + val definition = + AgentToolParam.ofFunction( + AgentToolParam.Function.builder() + .name(info.name) + .description(info.description ?: "") + .deferLoading(false) + .parameters( + AgentToolParam.Function.Parameters.builder() + .apply { + info.schema.fields().forEach { (name, value) -> + putAdditionalProperty(name, JsonValue.fromJsonNode(value)) + } + } + .build() + ) + .build() + ) + return AgentFunctionTool( + definition, + Function { arguments -> + val json = toJsonString(arguments) + require( + hasArgumentShape(jsonMapper().readTree(json), info.schema, info.schema) + ) { + "Function arguments do not match the declared parameter shape" + } + handler.apply(responseTypeFromJson(json, parametersType)) + }, + ) + } + } +} + +// Check presence and JSON types before Jackson can supply POJO defaults or coerce scalar values. +// Application validation remains responsible for business rules and schema format constraints. +private fun hasArgumentShape(value: JsonNode, schema: JsonNode, root: JsonNode): Boolean { + schema["\$ref"]?.asText()?.let { reference -> + if (reference != "#" && !reference.startsWith("#/")) return false + val target = root.at(reference.substring(1)) + if (target.isMissingNode || !hasArgumentShape(value, target, root)) return false + } + schema["anyOf"]?.let { options -> + if (options.none { hasArgumentShape(value, it, root) }) return false + } + schema["type"]?.let { type -> + val types = if (type.isArray) type.toList() else listOf(type) + if ( + types.none { + when (it.asText()) { + "object" -> value.isObject + "array" -> value.isArray + "string" -> value.isTextual + "boolean" -> value.isBoolean + "null" -> value.isNull + "number" -> value.isNumber + "integer" -> + value.isNumber && value.decimalValue().stripTrailingZeros().scale() <= 0 + else -> false + } + } + ) + return false + } + if (value.isObject) { + if (schema.path("required").any { !value.has(it.asText()) }) return false + val properties = schema.path("properties") + for ((name, child) in value.fields()) { + val property = properties[name] + if (property != null && !hasArgumentShape(child, property, root)) return false + if ( + property == null && + schema.path("additionalProperties").isBoolean && + !schema.path("additionalProperties").asBoolean() + ) + return false + } + } + if (value.isArray) { + schema["items"]?.let { itemSchema -> + if (value.any { !hasArgumentShape(it, itemSchema, root) }) return false + } + } + return true +} diff --git a/openai-java-core/src/main/kotlin/com/openai/lib/beta/agents/AgentFunctionTool.kt b/openai-java-core/src/main/kotlin/com/openai/lib/beta/agents/AgentFunctionTool.kt deleted file mode 100644 index 0ff1a5dde..000000000 --- a/openai-java-core/src/main/kotlin/com/openai/lib/beta/agents/AgentFunctionTool.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.openai.lib.beta.agents - -import com.openai.core.JsonSchemaLocalValidation -import com.openai.core.JsonValue -import com.openai.core.extractFunctionInfo -import com.openai.core.responseTypeFromJson -import com.openai.core.toJsonString -import com.openai.models.beta.agents.AgentTool -import java.util.concurrent.CompletionStage -import java.util.function.Function - -/** - * A beta Agent function definition and its local, typed application callback. Argument classes use - * the SDK's class-based schema and Jackson deserialization conventions. Bind dependencies with a - * closure or method reference; only the argument class becomes part of the hosted definition. - */ -class AgentFunctionTool -private constructor( - private val definition: AgentTool, - private val handler: Function, R>, -) { - /** Add this definition to the agent's tools when creating or updating its configuration. */ - fun definition(): AgentTool = definition - - fun name(): String = definition.asFunction().name() - - /** Register with `toolHandler`, or `asyncToolHandler` for a binding created by [ofAsync]. */ - fun handler(): Function, R> = handler - - companion object { - /** - * The callback returns the same string, map, content list, or null as a raw tool handler. - */ - @JvmStatic - fun of(parametersType: Class, handler: Function): AgentFunctionTool = - bind(parametersType, Function { handler.apply(it) }) - - /** The existing stream dispatcher awaits the callback's stage before continuing. */ - @JvmStatic - fun ofAsync( - parametersType: Class, - handler: Function>, - ): AgentFunctionTool> = - bind(parametersType, Function { handler.apply(it) }) - - private fun bind( - parametersType: Class, - handler: Function, - ): AgentFunctionTool { - val info = extractFunctionInfo(parametersType, JsonSchemaLocalValidation.YES) - val definition = - AgentTool.ofFunction( - AgentTool.Function.builder() - .name(info.name) - .description(info.description ?: "") - .deferLoading(false) - .parameters(JsonValue.fromJsonNode(info.schema)) - .build() - ) - return AgentFunctionTool( - definition, - Function { arguments -> - handler.apply(responseTypeFromJson(toJsonString(arguments), parametersType)) - }, - ) - } - } -} diff --git a/openai-java-core/src/test/kotlin/com/openai/lib/beta/agents/AgentFunctionToolTest.kt b/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt similarity index 53% rename from openai-java-core/src/test/kotlin/com/openai/lib/beta/agents/AgentFunctionToolTest.kt rename to openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt index 469cca585..98fc2b6de 100644 --- a/openai-java-core/src/test/kotlin/com/openai/lib/beta/agents/AgentFunctionToolTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt @@ -1,9 +1,11 @@ -package com.openai.lib.beta.agents +package com.openai.helpers.beta.agents import com.fasterxml.jackson.annotation.JsonClassDescription import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.JsonNode import com.openai.core.jsonMapper +import com.openai.models.beta.agents.sessions.SessionCreateParams +import java.util.Optional import java.util.concurrent.CompletableFuture import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy @@ -16,6 +18,19 @@ internal class AgentFunctionToolTest { class Lookup(val orderId: String) + class PublicFields { + @JvmField var asset: String? = null + } + + class NestedFields { + @JvmField var wallet: PublicFields? = null + @JvmField var others: List? = null + } + + class OptionalFields { + @JvmField var asset: Optional = Optional.empty() + } + private class WalletActions(private val network: String) { fun balance(args: Balance): Map = mapOf("type" to "balance", "content" to args.asset, "network" to network) @@ -26,6 +41,9 @@ internal class AgentFunctionToolTest { val wallet = WalletActions("test-network") val tool = AgentFunctionTool.of(Balance::class.java, wallet::balance) val definition = jsonMapper().valueToTree(tool.definition().validate()) + SessionCreateParams.Agent.builder().addTool(tool.definition()).build().validate() + assertThat(tool.definition().asFunction().parameters()._additionalProperties()) + .containsKey("properties") assertThat(tool.name()).isEqualTo("wallet_balance") assertThat(definition.path("type").asText()).isEqualTo("function") assertThat(definition.path("description").asText()).isEqualTo("Look up a wallet balance.") @@ -64,8 +82,57 @@ internal class AgentFunctionToolTest { } for (arguments in listOf(emptyMap(), mapOf("asset" to emptyList()))) { assertThatThrownBy { tool.handler().apply(arguments) } - .hasMessageContaining("Error parsing JSON") + .hasMessageContaining("parameter shape") + } + assertThat(invocations).isZero() + } + + @Test + fun publicFieldsCannotDefaultOrCoerceBeforeApplicationCalls() { + var invocations = 0 + val tool = + AgentFunctionTool.of(NestedFields::class.java) { + invocations++ + "unused" + } + for (wallet in + listOf( + emptyMap(), + mapOf("asset" to null), + mapOf("asset" to 123), + mapOf("asset" to "ETH", "extra" to true), + )) { + assertThatThrownBy { + tool.handler().apply(mapOf("wallet" to wallet, "others" to emptyList())) + } + .isInstanceOf(IllegalArgumentException::class.java) + assertThatThrownBy { + tool + .handler() + .apply( + mapOf("wallet" to mapOf("asset" to "ETH"), "others" to listOf(wallet)) + ) + } + .isInstanceOf(IllegalArgumentException::class.java) } assertThat(invocations).isZero() + tool + .handler() + .apply( + mapOf( + "wallet" to mapOf("asset" to "ETH"), + "others" to listOf(mapOf("asset" to "BTC")), + ) + ) + assertThat(invocations).isEqualTo(1) + } + + @Test + fun optionalArgumentsAllowExplicitNull() { + val tool = AgentFunctionTool.of(OptionalFields::class.java) { it.asset.orElse("none") } + assertThat(tool.handler().apply(mapOf("asset" to null))).isEqualTo("none") + assertThat(tool.handler().apply(mapOf("asset" to "ETH"))).isEqualTo("ETH") + assertThatThrownBy { tool.handler().apply(emptyMap()) } + .isInstanceOf(IllegalArgumentException::class.java) } } diff --git a/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt b/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt index 3954166d1..003941384 100644 --- a/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt @@ -6,7 +6,7 @@ import com.openai.core.ClientOptions import com.openai.core.RequestOptions import com.openai.core.http.* import com.openai.core.jsonMapper -import com.openai.lib.beta.agents.AgentFunctionTool +import com.openai.helpers.beta.agents.AgentFunctionTool import com.openai.models.beta.agents.* import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream diff --git a/openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java b/openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java index c1f9b2582..39d4bdd2f 100644 --- a/openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java +++ b/openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.annotation.JsonClassDescription; import com.fasterxml.jackson.annotation.JsonTypeName; import com.openai.client.OpenAIClient; -import com.openai.lib.beta.agents.AgentFunctionTool; +import com.openai.helpers.beta.agents.AgentFunctionTool; import com.openai.models.beta.agents.AgentSessionStreamParams; import com.openai.models.beta.agents.sessions.SessionCreateParams; import java.util.Map; From b9b959e186261a09812831ee2b017df77699a7b4 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Wed, 30 Sep 2026 22:33:07 +0000 Subject: [PATCH 3/6] fix(beta): require object schemas for application tools --- .../helpers/beta/agents/AgentFunctionTool.kt | 3 +++ .../helpers/beta/agents/AgentFunctionToolTest.kt | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt b/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt index 62d24d3dc..47326e9f6 100644 --- a/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt +++ b/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt @@ -50,6 +50,9 @@ private constructor( handler: Function, ): AgentFunctionTool { val info = extractFunctionInfo(parametersType, JsonSchemaLocalValidation.YES) + require(info.schema.path("type").asText() == "object") { + "Function parameters must be an object class" + } val definition = AgentToolParam.ofFunction( AgentToolParam.Function.builder() diff --git a/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt b/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt index 98fc2b6de..000d70574 100644 --- a/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt @@ -31,6 +31,11 @@ internal class AgentFunctionToolTest { @JvmField var asset: Optional = Optional.empty() } + enum class Asset { + ETH, + BTC, + } + private class WalletActions(private val network: String) { fun balance(args: Balance): Map = mapOf("type" to "balance", "content" to args.asset, "network" to network) @@ -135,4 +140,13 @@ internal class AgentFunctionToolTest { assertThatThrownBy { tool.handler().apply(emptyMap()) } .isInstanceOf(IllegalArgumentException::class.java) } + + @Test + fun rejectsParameterClassesThatCannotReceiveObjectArguments() { + for (type in listOf(String::class.java, Array::class.java, Asset::class.java)) { + assertThatThrownBy { AgentFunctionTool.of(type) { "unused" } } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("object class") + } + } } From 8b9bfb7cadcba95bc734c25c5a7dc9e9645f8421 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Wed, 30 Sep 2026 23:14:02 +0000 Subject: [PATCH 4/6] ci: rerun Java checks with corrected OkTest pagination expectations From e53294cd7b936b20b29d44cc2ee58131ba10b305 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Wed, 30 Sep 2026 23:22:48 +0000 Subject: [PATCH 5/6] fix(beta): reject unenforced tool argument constraints --- docs/helpers/agent-sessions.md | 2 + .../helpers/beta/agents/AgentFunctionTool.kt | 34 ++++++++++- .../beta/agents/AgentFunctionToolTest.kt | 61 +++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/docs/helpers/agent-sessions.md b/docs/helpers/agent-sessions.md index cfedd344b..af2aa4503 100644 --- a/docs/helpers/agent-sessions.md +++ b/docs/helpers/agent-sessions.md @@ -82,6 +82,8 @@ following active sessions and advanced orchestration. Bind an argument class and application callback once. Jackson's `@JsonTypeName` and `@JsonClassDescription` set the hosted name and description, as with other class-based SDK tools. +Unsupported constraint annotations, such as `@Schema(maximum = "10")`, are rejected; +validate those business rules in your callback instead. ```java import com.fasterxml.jackson.annotation.JsonClassDescription; diff --git a/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt b/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt index 47326e9f6..9107f8f7b 100644 --- a/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt +++ b/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt @@ -15,6 +15,8 @@ import java.util.function.Function * A beta Agent function definition and its local, typed application callback. Argument classes use * the SDK's class-based schema and Jackson deserialization conventions. Bind dependencies with a * closure or method reference; only the argument class becomes part of the hosted definition. + * Unsupported schema constraints are rejected at binding time; validate business rules in the + * callback. */ class AgentFunctionTool private constructor( @@ -53,6 +55,7 @@ private constructor( require(info.schema.path("type").asText() == "object") { "Function parameters must be an object class" } + requireSupportedConstraints(info.schema) val definition = AgentToolParam.ofFunction( AgentToolParam.Function.builder() @@ -86,9 +89,36 @@ private constructor( } } -// Check presence and JSON types before Jackson can supply POJO defaults or coerce scalar values. -// Application validation remains responsible for business rules and schema format constraints. +private fun requireSupportedConstraints(schema: JsonNode) { + for ((keyword, value) in schema.fields()) { + when (keyword) { + "properties", + "\$defs", + "anyOf" -> value.forEach(::requireSupportedConstraints) + "items" -> requireSupportedConstraints(value) + "\$schema", + "\$id", + "\$ref", + "type", + "required", + "description", + "title", + "additionalProperties", + "enum", + "const" -> Unit + else -> + throw IllegalArgumentException( + "AgentFunctionTool does not support schema constraint '$keyword'; " + + "remove the constraint annotation and validate it in your callback" + ) + } + } +} + +// Check declared values and JSON types before Jackson supplies defaults or coerces scalar values. private fun hasArgumentShape(value: JsonNode, schema: JsonNode, root: JsonNode): Boolean { + schema["enum"]?.let { if (value !in it) return false } + schema["const"]?.let { if (value != it) return false } schema["\$ref"]?.asText()?.let { reference -> if (reference != "#" && !reference.startsWith("#/")) return false val target = root.at(reference.substring(1)) diff --git a/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt b/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt index 000d70574..bd38b7f5c 100644 --- a/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt @@ -5,6 +5,8 @@ import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.JsonNode import com.openai.core.jsonMapper import com.openai.models.beta.agents.sessions.SessionCreateParams +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Schema import java.util.Optional import java.util.concurrent.CompletableFuture import org.assertj.core.api.Assertions.assertThat @@ -36,6 +38,24 @@ internal class AgentFunctionToolTest { BTC, } + class LimitedAmount(@get:Schema(maximum = "10") val amount: Int) + + class RestrictedAddress(@get:Schema(pattern = "^0x") val address: String) + + class LimitedAssets(@get:ArraySchema(maxItems = 1) val assets: List) + + class FormattedAddress(@get:Schema(format = "email") val address: String) + + class NestedConstraint(val wallets: List) + + class EnumAsset(val asset: Asset) + + class AllowedAsset(@get:Schema(allowableValues = ["ETH", "BTC"]) val asset: String) + + class ConstantAsset(@get:Schema(allowableValues = ["ETH"]) val asset: String) + + class KeywordFields(val maximum: String, val pattern: String, val maxItems: Int) + private class WalletActions(private val network: String) { fun balance(args: Balance): Map = mapOf("type" to "balance", "content" to args.asset, "network" to network) @@ -149,4 +169,45 @@ internal class AgentFunctionToolTest { .hasMessageContaining("object class") } } + + @Test + fun rejectsUnenforcedConstraintsBeforeBinding() { + for (type in + listOf( + LimitedAmount::class.java, + RestrictedAddress::class.java, + LimitedAssets::class.java, + FormattedAddress::class.java, + NestedConstraint::class.java, + )) { + assertThatThrownBy { AgentFunctionTool.of(type) { "unused" } } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("does not support schema constraint") + .hasMessageContaining("validate it in your callback") + } + val tool = AgentFunctionTool.of(KeywordFields::class.java) { it.maximum } + assertThat( + tool + .handler() + .apply(mapOf("maximum" to "limit", "pattern" to "text", "maxItems" to 1)) + ) + .isEqualTo("limit") + } + + @Test + fun enforcesEnumAndConstantValuesBeforeCallingApplication() { + var invocations = 0 + for (type in + listOf(EnumAsset::class.java, AllowedAsset::class.java, ConstantAsset::class.java)) { + val tool = + AgentFunctionTool.of(type) { + invocations++ + "valid" + } + assertThat(tool.handler().apply(mapOf("asset" to "ETH"))).isEqualTo("valid") + assertThatThrownBy { tool.handler().apply(mapOf("asset" to "invalid")) } + .hasMessageContaining("parameter shape") + } + assertThat(invocations).isEqualTo(3) + } } From a42f1a5abab7955ac662443e6f67ff61f09bcfc4 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Thu, 1 Oct 2026 14:11:34 +0000 Subject: [PATCH 6/6] docs(agents): use generic catalog tool examples --- docs/helpers/agent-sessions.md | 2 +- .../beta/agents/AgentFunctionToolTest.kt | 96 ++++++++++--------- .../beta/agents/AgentSessionStreamTest.kt | 34 +++---- .../openai/example/BetaAgentToolsExample.java | 48 +++++----- 4 files changed, 91 insertions(+), 89 deletions(-) diff --git a/docs/helpers/agent-sessions.md b/docs/helpers/agent-sessions.md index af2aa4503..be1b53705 100644 --- a/docs/helpers/agent-sessions.md +++ b/docs/helpers/agent-sessions.md @@ -109,4 +109,4 @@ binding to an existing session using its hosted tool name. Application services and credentials stay in the closure or bound method. Use strings for exact decimal amounts, as the existing event decoder represents JSON fractions as doubles. See [`BetaAgentToolsExample`](../../openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java) -for a read-only wallet action. +for a read-only catalog lookup. diff --git a/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt b/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt index bd38b7f5c..688623539 100644 --- a/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt @@ -14,74 +14,76 @@ import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.Test internal class AgentFunctionToolTest { - @JsonTypeName("wallet_balance") - @JsonClassDescription("Look up a wallet balance.") - class Balance(val asset: String) + @JsonTypeName("lookup_item") + @JsonClassDescription("Look up a catalog item.") + class LookupItem(val itemId: String) - class Lookup(val orderId: String) + class Lookup(val itemId: String) class PublicFields { - @JvmField var asset: String? = null + @JvmField var itemId: String? = null } class NestedFields { - @JvmField var wallet: PublicFields? = null + @JvmField var item: PublicFields? = null @JvmField var others: List? = null } class OptionalFields { - @JvmField var asset: Optional = Optional.empty() + @JvmField var itemId: Optional = Optional.empty() } - enum class Asset { - ETH, - BTC, + enum class ItemId { + ITEM_A, + ITEM_B, } class LimitedAmount(@get:Schema(maximum = "10") val amount: Int) - class RestrictedAddress(@get:Schema(pattern = "^0x") val address: String) + class RestrictedAddress(@get:Schema(pattern = "^item-") val address: String) - class LimitedAssets(@get:ArraySchema(maxItems = 1) val assets: List) + class LimitedItems(@get:ArraySchema(maxItems = 1) val items: List) class FormattedAddress(@get:Schema(format = "email") val address: String) - class NestedConstraint(val wallets: List) + class NestedConstraint(val items: List) - class EnumAsset(val asset: Asset) + class EnumItemId(val itemId: ItemId) - class AllowedAsset(@get:Schema(allowableValues = ["ETH", "BTC"]) val asset: String) + class AllowedItemId(@get:Schema(allowableValues = ["ITEM_A", "ITEM_B"]) val itemId: String) - class ConstantAsset(@get:Schema(allowableValues = ["ETH"]) val asset: String) + class ConstantItemId(@get:Schema(allowableValues = ["ITEM_A"]) val itemId: String) class KeywordFields(val maximum: String, val pattern: String, val maxItems: Int) - private class WalletActions(private val network: String) { - fun balance(args: Balance): Map = - mapOf("type" to "balance", "content" to args.asset, "network" to network) + private class CatalogActions(private val catalogId: String) { + fun lookup(args: LookupItem): Map = + mapOf("type" to "lookup", "content" to args.itemId, "catalogId" to catalogId) } @Test fun bindsApplicationActionWithoutExposingDependencies() { - val wallet = WalletActions("test-network") - val tool = AgentFunctionTool.of(Balance::class.java, wallet::balance) + val catalog = CatalogActions("test-catalog") + val tool = AgentFunctionTool.of(LookupItem::class.java, catalog::lookup) val definition = jsonMapper().valueToTree(tool.definition().validate()) SessionCreateParams.Agent.builder().addTool(tool.definition()).build().validate() assertThat(tool.definition().asFunction().parameters()._additionalProperties()) .containsKey("properties") - assertThat(tool.name()).isEqualTo("wallet_balance") + assertThat(tool.name()).isEqualTo("lookup_item") assertThat(definition.path("type").asText()).isEqualTo("function") - assertThat(definition.path("description").asText()).isEqualTo("Look up a wallet balance.") + assertThat(definition.path("description").asText()).isEqualTo("Look up a catalog item.") assertThat(definition.has("function")).isFalse() assertThat( definition.path("parameters").path("properties").fieldNames().asSequence().toList() ) - .containsExactly("asset") + .containsExactly("itemId") assertThat(definition.path("parameters").path("required").map { it.asText() }) - .containsExactly("asset") + .containsExactly("itemId") assertThat(definition.path("parameters").path("additionalProperties").asBoolean()).isFalse() - assertThat(tool.handler().apply(mapOf("asset" to "ETH"))) - .isEqualTo(mapOf("type" to "balance", "content" to "ETH", "network" to "test-network")) + assertThat(tool.handler().apply(mapOf("itemId" to "ITEM_A"))) + .isEqualTo( + mapOf("type" to "lookup", "content" to "ITEM_A", "catalogId" to "test-catalog") + ) } @Test @@ -89,23 +91,23 @@ internal class AgentFunctionToolTest { val pending = CompletableFuture() val tool = AgentFunctionTool.ofAsync(Lookup::class.java) { args -> - assertThat(args.orderId).isEqualTo("A123") + assertThat(args.itemId).isEqualTo("ITEM_A") pending } assertThat(tool.name()).isEqualTo("Lookup") tool.definition().validate() - assertThat(tool.handler().apply(mapOf("orderId" to "A123"))).isSameAs(pending) + assertThat(tool.handler().apply(mapOf("itemId" to "ITEM_A"))).isSameAs(pending) } @Test fun parsingFailsBeforeCallingApplication() { var invocations = 0 val tool = - AgentFunctionTool.of(Balance::class.java) { + AgentFunctionTool.of(LookupItem::class.java) { invocations++ "unused" } - for (arguments in listOf(emptyMap(), mapOf("asset" to emptyList()))) { + for (arguments in listOf(emptyMap(), mapOf("itemId" to emptyList()))) { assertThatThrownBy { tool.handler().apply(arguments) } .hasMessageContaining("parameter shape") } @@ -120,22 +122,22 @@ internal class AgentFunctionToolTest { invocations++ "unused" } - for (wallet in + for (item in listOf( emptyMap(), - mapOf("asset" to null), - mapOf("asset" to 123), - mapOf("asset" to "ETH", "extra" to true), + mapOf("itemId" to null), + mapOf("itemId" to 123), + mapOf("itemId" to "ITEM_A", "extra" to true), )) { assertThatThrownBy { - tool.handler().apply(mapOf("wallet" to wallet, "others" to emptyList())) + tool.handler().apply(mapOf("item" to item, "others" to emptyList())) } .isInstanceOf(IllegalArgumentException::class.java) assertThatThrownBy { tool .handler() .apply( - mapOf("wallet" to mapOf("asset" to "ETH"), "others" to listOf(wallet)) + mapOf("item" to mapOf("itemId" to "ITEM_A"), "others" to listOf(item)) ) } .isInstanceOf(IllegalArgumentException::class.java) @@ -145,8 +147,8 @@ internal class AgentFunctionToolTest { .handler() .apply( mapOf( - "wallet" to mapOf("asset" to "ETH"), - "others" to listOf(mapOf("asset" to "BTC")), + "item" to mapOf("itemId" to "ITEM_A"), + "others" to listOf(mapOf("itemId" to "ITEM_B")), ) ) assertThat(invocations).isEqualTo(1) @@ -154,16 +156,16 @@ internal class AgentFunctionToolTest { @Test fun optionalArgumentsAllowExplicitNull() { - val tool = AgentFunctionTool.of(OptionalFields::class.java) { it.asset.orElse("none") } - assertThat(tool.handler().apply(mapOf("asset" to null))).isEqualTo("none") - assertThat(tool.handler().apply(mapOf("asset" to "ETH"))).isEqualTo("ETH") + val tool = AgentFunctionTool.of(OptionalFields::class.java) { it.itemId.orElse("none") } + assertThat(tool.handler().apply(mapOf("itemId" to null))).isEqualTo("none") + assertThat(tool.handler().apply(mapOf("itemId" to "ITEM_A"))).isEqualTo("ITEM_A") assertThatThrownBy { tool.handler().apply(emptyMap()) } .isInstanceOf(IllegalArgumentException::class.java) } @Test fun rejectsParameterClassesThatCannotReceiveObjectArguments() { - for (type in listOf(String::class.java, Array::class.java, Asset::class.java)) { + for (type in listOf(String::class.java, Array::class.java, ItemId::class.java)) { assertThatThrownBy { AgentFunctionTool.of(type) { "unused" } } .isInstanceOf(IllegalArgumentException::class.java) .hasMessageContaining("object class") @@ -176,7 +178,7 @@ internal class AgentFunctionToolTest { listOf( LimitedAmount::class.java, RestrictedAddress::class.java, - LimitedAssets::class.java, + LimitedItems::class.java, FormattedAddress::class.java, NestedConstraint::class.java, )) { @@ -198,14 +200,14 @@ internal class AgentFunctionToolTest { fun enforcesEnumAndConstantValuesBeforeCallingApplication() { var invocations = 0 for (type in - listOf(EnumAsset::class.java, AllowedAsset::class.java, ConstantAsset::class.java)) { + listOf(EnumItemId::class.java, AllowedItemId::class.java, ConstantItemId::class.java)) { val tool = AgentFunctionTool.of(type) { invocations++ "valid" } - assertThat(tool.handler().apply(mapOf("asset" to "ETH"))).isEqualTo("valid") - assertThatThrownBy { tool.handler().apply(mapOf("asset" to "invalid")) } + assertThat(tool.handler().apply(mapOf("itemId" to "ITEM_A"))).isEqualTo("valid") + assertThatThrownBy { tool.handler().apply(mapOf("itemId" to "invalid")) } .hasMessageContaining("parameter shape") } assertThat(invocations).isEqualTo(3) diff --git a/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt b/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt index 003941384..9d8482c82 100644 --- a/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt @@ -238,59 +238,59 @@ internal class AgentSessionStreamTest { private fun params() = AgentSessionStreamParams.builder().sessionId("s").input("hello") - class WalletBalance(val asset: String) + class LookupItem(val itemId: String) @ParameterizedTest @ValueSource(booleans = [false, true]) fun typedActionsReuseTheDispatcher(async: Boolean) { for (asyncHandler in listOf(false, true)) { val calls = mutableListOf() - val action = { args: WalletBalance -> - calls.add(args.asset) - mapOf("type" to "balance", "content" to args.asset) + val action = { args: LookupItem -> + calls.add(args.itemId) + mapOf("type" to "item", "content" to args.itemId) } - val p = params().toolHandler("raw") { mapOf("raw" to it["asset"]) } + val p = params().toolHandler("raw") { mapOf("raw" to it["itemId"]) } if (asyncHandler) { val tool = - AgentFunctionTool.ofAsync(WalletBalance::class.java) { + AgentFunctionTool.ofAsync(LookupItem::class.java) { CompletableFuture.completedFuture(action(it)) } p.asyncToolHandler(tool.name(), tool.handler()) } else { - val tool = AgentFunctionTool.of(WalletBalance::class.java, action) + val tool = AgentFunctionTool.of(LookupItem::class.java, action) p.toolHandler(tool.name(), tool.handler()) } val t = Transport( listOf( turn("created"), - call(name = "WalletBalance", args = "{\"asset\":\"ETH\"}"), + call(name = "LookupItem", args = "{\"itemId\":\"ITEM_A\"}"), call( - name = "WalletBalance", + name = "LookupItem", event = "redelivered", - args = "{\"asset\":\"ETH\"}", + args = "{\"itemId\":\"ITEM_A\"}", ), call( id = "legacy", - name = "WalletBalance", - args = "\"{\\\"asset\\\":\\\"BTC\\\"}\"", + name = "LookupItem", + args = "\"{\\\"itemId\\\":\\\"ITEM_B\\\"}\"", ), - call(id = "invalid", name = "WalletBalance", args = "{}"), - call(id = "raw", name = "raw", args = "{\"asset\":\"USD\"}"), + call(id = "invalid", name = "LookupItem", args = "{}"), + call(id = "raw", name = "raw", args = "{\"itemId\":\"ITEM_C\"}"), turn("completed"), idle(), ) ) consume(t, async, p.build()) - assertThat(calls).containsExactly("ETH", "BTC") + assertThat(calls).containsExactly("ITEM_A", "ITEM_B") val results = t.posts.drop(1).map { it.path("events").first() } assertThat(results).hasSize(4) assertThat(results[0].path("output").asText()) - .isEqualTo("{\"type\":\"balance\",\"content\":\"ETH\"}") + .isEqualTo("{\"type\":\"item\",\"content\":\"ITEM_A\"}") assertThat(results[1].path("success").asBoolean()).isTrue() assertThat(results[2].path("success").asBoolean()).isFalse() assertThat(results[2].path("error").asText()).isEqualTo("Tool handler failed.") - assertThat(results[3].path("output").asText()).isEqualTo("{\"raw\":\"USD\"}") + assertThat(results[3].path("output").asText()).isEqualTo("{\"raw\":\"ITEM_C\"}") } } diff --git a/openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java b/openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java index 39d4bdd2f..795017aac 100644 --- a/openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java +++ b/openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java @@ -9,41 +9,41 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; -/** Bind a read-only, Coinbase-style application action without exposing application dependencies. */ +/** Bind a read-only catalog lookup without exposing application dependencies. */ public final class BetaAgentToolsExample { - @JsonTypeName("wallet_balance") - @JsonClassDescription("Look up a wallet balance.") - public static final class Balance { - public String asset; + @JsonTypeName("lookup_item") + @JsonClassDescription("Look up a catalog item.") + public static final class LookupItem { + public String itemId; } - public static final class WalletActions { - private final String network; + public static final class CatalogActions { + private final String catalogId; - public WalletActions(String network) { - this.network = network; + public CatalogActions(String catalogId) { + this.catalogId = catalogId; } - public Map balance(Balance args) { - // Replace this synthetic response with your application's authorized wallet lookup. - return Map.of("asset", args.asset, "network", network, "balance", "0"); + public Map lookup(LookupItem args) { + // Replace this synthetic response with your application's authorized catalog lookup. + return Map.of("itemId", args.itemId, "catalogId", catalogId, "name", "Example item"); } } - public static SessionCreateParams.Agent agent(String model, WalletActions wallet) { - var balance = AgentFunctionTool.of(Balance.class, wallet::balance); + public static SessionCreateParams.Agent agent(String model, CatalogActions catalog) { + var lookup = AgentFunctionTool.of(LookupItem.class, catalog::lookup); return SessionCreateParams.Agent.builder() .model(model) - .addTool(balance.definition()) + .addTool(lookup.definition()) .build(); } - public static void followUp(OpenAIClient client, String sessionId, WalletActions wallet) { - var balance = AgentFunctionTool.of(Balance.class, wallet::balance); + public static void followUp(OpenAIClient client, String sessionId, CatalogActions catalog) { + var lookup = AgentFunctionTool.of(LookupItem.class, catalog::lookup); var params = AgentSessionStreamParams.builder() .sessionId(sessionId) - .input("What is my ETH balance?") - .toolHandler(balance.name(), balance.handler()) + .input("Look up item ITEM_A.") + .toolHandler(lookup.name(), lookup.handler()) .build(); try (var stream = client.beta().agents().sessions().stream(params)) { stream.stream() @@ -51,13 +51,13 @@ public static void followUp(OpenAIClient client, String sessionId, WalletActions } } - public static AgentSessionStreamParams asyncParams(String sessionId, WalletActions wallet) { - var balance = AgentFunctionTool.ofAsync( - Balance.class, args -> CompletableFuture.completedFuture(wallet.balance(args))); + public static AgentSessionStreamParams asyncParams(String sessionId, CatalogActions catalog) { + var lookup = AgentFunctionTool.ofAsync( + LookupItem.class, args -> CompletableFuture.completedFuture(catalog.lookup(args))); return AgentSessionStreamParams.builder() .sessionId(sessionId) - .input("What is my ETH balance?") - .asyncToolHandler(balance.name(), balance.handler()) + .input("Look up item ITEM_A.") + .asyncToolHandler(lookup.name(), lookup.handler()) .build(); } }