From 5b0407f2cc8dea77675e65d25fa9730c97677f0b Mon Sep 17 00:00:00 2001 From: John Oliver <1615532+johnoliver@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:25:01 +0000 Subject: [PATCH 1/4] Add agentserver library --- sdk/agentserver/README.md | 304 +++++ sdk/agentserver/azure-agentserver-api/pom.xml | 83 ++ .../agentserver/api/AgentReference.java | 27 + .../agentserver/api/AgentReferenceType.java | 30 + .../api/AgentServerCreateResponse.java | 117 ++ .../api/AgentServerResponseContext.java | 355 +++++ .../api/AgentServerResponseEventStream.java | 1192 +++++++++++++++++ .../api/AgentServerResponseItemList.java | 17 + .../api/AgentServerResponsesApi.java | 981 ++++++++++++++ .../agentserver/api/AgentServerVersion.java | 45 + .../microsoft/agentserver/api/ApiError.java | 97 ++ .../agentserver/api/ApiException.java | 50 + .../agentserver/api/CreateResponse.java | 20 + .../agentserver/api/EventReplayStore.java | 164 +++ .../agentserver/api/FoundryEnvironment.java | 167 +++ .../microsoft/agentserver/api/HealthApi.java | 41 + .../agentserver/api/IsolationContext.java | 131 ++ .../agentserver/api/Observability.java | 404 ++++++ .../agentserver/api/PlatformHeaders.java | 90 ++ .../agentserver/api/RequestMetadata.java | 107 ++ .../agentserver/api/ResponseBuilder.java | 105 ++ .../agentserver/api/ResponseContext.java | 325 +++++ .../agentserver/api/ResponseEvent.java | 16 + .../agentserver/api/ResponseEventStream.java | 338 +++++ .../agentserver/api/ResponseHandler.java | 52 + .../agentserver/api/ResponseStreamReplay.java | 31 + .../agentserver/api/ResponsesApi.java | 292 ++++ .../agentserver/api/ResponsesProvider.java | 147 ++ .../agentserver/api/SessionIdResolver.java | 184 +++ .../agentserver/api/TrustStoreInstaller.java | 635 +++++++++ .../FoundryStorageException.java | 59 + .../FoundryStorageProvider.java | 656 +++++++++ .../api/implementation/IdGenerator.java | 155 +++ .../InMemoryResponseProvider.java | 333 +++++ .../api/implementation/ItemConversion.java | 341 +++++ .../api/implementation/package-info.java | 11 + .../agentserver/api/package-info.java | 36 + .../serialization/ObjectMapperFactory.java | 172 +++ .../BackgroundResponseIntegrationTest.java | 179 +++ .../api/CancelResponseIntegrationTest.java | 169 +++ .../api/ClientDisconnectIntegrationTest.java | 183 +++ .../api/EndToEndIntegrationTest.java | 507 +++++++ .../agentserver/api/IdGeneratorTest.java | 47 + ...MemoryResponseProviderIntegrationTest.java | 319 +++++ .../api/ResponseContextIntegrationTest.java | 284 ++++ .../ResponseEventStreamIntegrationTest.java | 508 +++++++ .../ResponseIdOverrideIntegrationTest.java | 108 ++ .../api/ResponsesApiIntegrationTest.java | 367 +++++ .../api/SerializationIntegrationTest.java | 376 ++++++ .../api/SessionIdResolverTest.java | 114 ++ .../FoundryStorageProviderActivationTest.java | 35 + .../azure-agentserver-doc-samples/pom.xml | 84 ++ .../java/com/microsoft/agentserver/Main.java | 17 + .../agentserver/sample1/EchoHandler.java | 67 + .../agentserver/sample1/Sample1Snippets.java | 38 + .../sample10/Sample10Snippets.java | 34 + .../sample10/StreamingUpstreamHandler.java | 56 + .../agentserver/sample2/Sample2Snippets.java | 29 + .../agentserver/sample2/StreamingHandler.java | 55 + .../agentserver/sample3/GreetingHandler.java | 38 + .../sample3/GreetingHandlerFullControl.java | 53 + .../agentserver/sample3/Sample3Snippets.java | 51 + .../sample3/StreamingGreetingHandler.java | 56 + .../agentserver/sample4/Sample4Snippets.java | 21 + .../agentserver/sample4/WeatherHandler.java | 87 ++ .../sample4/WeatherHandlerFullControl.java | 100 ++ .../agentserver/sample5/Sample5Snippets.java | 21 + .../sample5/StudyTutorHandler.java | 77 ++ .../sample6/MathSolverHandler.java | 64 + .../sample6/MathSolverHandlerFullControl.java | 81 ++ .../agentserver/sample6/Sample6Snippets.java | 21 + .../src/main/resources/log4j2.xml | 18 + .../azure-agentserver-echo-sample/Dockerfile | 14 + .../applicationinsights.json | 14 + .../docker-compose.yml | 15 + .../azure-agentserver-echo-sample/pom.xml | 62 + .../run-sample.sh | 32 + .../microsoft/agentserver/EchoHandler.java | 90 ++ .../java/com/microsoft/agentserver/Main.java | 16 + .../microsoft/agentserver/WeatherHandler.java | 86 ++ .../pom.xml | 115 ++ .../test/java/UpstreamOpenAISmokeTest.java | 524 ++++++++ .../financial/FinancialAgentSmokeTest.java | 600 +++++++++ .../azure-agentserver-langchain4j-bom/pom.xml | 99 ++ .../azure-agentserver-langchain4j/pom.xml | 70 + .../Langchain4jResponsesHandler.java | 481 +++++++ .../SupervisorAgentWithMemory.java | 10 + .../langchain4j/noop/NOOPSupervisorAgent.java | 34 + .../langchain4j/noop/NOOPUntypedAgent.java | 36 + .../src/main/resources/META-INF/beans.xml | 7 + .../api/langchain4j/OpenAIClientTest.java | 63 + .../azure-agentserver-api-jaxrs/pom.xml | 42 + .../api/jaxrs/ApiExceptionMapper.java | 36 + .../agentserver/api/jaxrs/HealthResource.java | 97 ++ .../jaxrs/InboundRequestLoggingFilter.java | 137 ++ .../api/jaxrs/ObjectMapperProvider.java | 23 + .../jaxrs/PlatformHeaderResponseFilter.java | 58 + .../api/jaxrs/ResponsesResource.java | 348 +++++ .../agentserver/api/jaxrs/RoutingFilter.java | 111 ++ .../api/jaxrs/SseResponseFilter.java | 40 + .../src/main/resources/META-INF/beans.xml | 7 + .../azure-agentserver-jersey/pom.xml | 68 + .../server/jersey/ExceptionMappers.java | 55 + .../agentserver/server/jersey/Filters.java | 58 + .../JerseyAgentServerAdaptorService.java | 94 ++ .../src/main/resources/META-INF/beans.xml | 7 + .../jersey/JerseyServerIntegrationTest.java | 747 +++++++++++ .../azure-agentserver-spring/pom.xml | 65 + .../agentserver/server/spring/Filters.java | 77 ++ .../server/spring/GlobalExceptionHandler.java | 65 + .../server/spring/HealthController.java | 57 + .../spring/InboundRequestLoggingFilter.java | 117 ++ .../server/spring/ObjectMapperConfig.java | 35 + .../server/spring/PlatformHeaderFilter.java | 80 ++ .../server/spring/ResponsesController.java | 324 +++++ .../SpringAgentServerAdaptorService.java | 156 +++ .../server/spring/SseHeaderFilter.java | 53 + .../server/spring/StreamRoutingFilter.java | 183 +++ .../Dockerfile | 16 + .../applicationinsights.json | 14 + .../docker-compose.yml | 12 + .../pom.xml | 130 ++ .../run-sample.sh | 26 + .../run.sh | 28 + .../src/main/java/GeneralAgent.java | 26 + .../src/main/java/Init.java | 54 + .../src/main/java/Main.java | 14 + .../src/main/java/MathsAgent.java | 29 + .../src/main/java/MathsTools.java | 41 + .../src/main/resources/META-INF/beans.xml | 7 + .../src/main/resources/application.properties | 2 + .../test/java/JerseyAgentServerService.java | 45 + .../src/test/java/Main.java | 29 + .../Dockerfile | 17 + .../applicationinsights.json | 15 + .../docker-compose.yml | 13 + .../pom.xml | 89 ++ .../run-sample.sh | 27 + .../run.sh | 28 + .../src/main/java/GeneralAgent.java | 26 + .../src/main/java/Init.java | 56 + .../src/main/java/Main.java | 13 + .../src/main/java/MathsAgent.java | 26 + .../src/main/java/MathsTools.java | 42 + .../src/main/resources/application.properties | 3 + .../src/test/java/Main.java | 28 + .../test/java/SpringAgentServerService.java | 16 + .../Dockerfile | 15 + .../applicationinsights.json | 14 + .../docker-compose.yml | 15 + .../pom.xml | 110 ++ .../run-sample.sh | 36 + .../scripts/run.sh | 8 + .../sample/financial/jersey/Main.java | 79 ++ .../jersey/OpenTelemetryRequestFilter.java | 79 ++ .../src/main/resources/log4j2.xml | 20 + .../pom.xml | 78 ++ .../sample/financial/AccountInfoAgent.java | 18 + .../sample/financial/AccountingAgent.java | 57 + .../sample/financial/BankTool.java | 50 + .../sample/financial/CreditAgent.java | 17 + .../sample/financial/ExchangeAgent.java | 15 + .../sample/financial/ExchangeTool.java | 33 + .../sample/financial/WithdrawAgent.java | 19 + .../src/main/resources/META-INF/beans.xml | 7 + sdk/agentserver/docs/developer-guide.md | 127 ++ sdk/agentserver/docs/end-user-guide.md | 143 ++ sdk/agentserver/mvnw | 316 +++++ sdk/agentserver/pom.xml | 335 +++++ 169 files changed, 20545 insertions(+) create mode 100644 sdk/agentserver/README.md create mode 100644 sdk/agentserver/azure-agentserver-api/pom.xml create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentReference.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentReferenceType.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerCreateResponse.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseContext.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseEventStream.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseItemList.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponsesApi.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerVersion.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ApiError.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ApiException.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/CreateResponse.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/EventReplayStore.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/FoundryEnvironment.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/HealthApi.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/IsolationContext.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/Observability.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/PlatformHeaders.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/RequestMetadata.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseBuilder.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseContext.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseEvent.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseEventStream.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseHandler.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseStreamReplay.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponsesApi.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponsesProvider.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/SessionIdResolver.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/TrustStoreInstaller.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/FoundryStorageException.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/FoundryStorageProvider.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/IdGenerator.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/InMemoryResponseProvider.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/ItemConversion.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/package-info.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/package-info.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/serialization/ObjectMapperFactory.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/BackgroundResponseIntegrationTest.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/CancelResponseIntegrationTest.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ClientDisconnectIntegrationTest.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/EndToEndIntegrationTest.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/IdGeneratorTest.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/InMemoryResponseProviderIntegrationTest.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponseContextIntegrationTest.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponseEventStreamIntegrationTest.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponseIdOverrideIntegrationTest.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponsesApiIntegrationTest.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/SerializationIntegrationTest.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/SessionIdResolverTest.java create mode 100644 sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/implementation/FoundryStorageProviderActivationTest.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/pom.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/Main.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample1/EchoHandler.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample1/Sample1Snippets.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample10/Sample10Snippets.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample10/StreamingUpstreamHandler.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample2/Sample2Snippets.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample2/StreamingHandler.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/GreetingHandler.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/GreetingHandlerFullControl.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/Sample3Snippets.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/StreamingGreetingHandler.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample4/Sample4Snippets.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample4/WeatherHandler.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample4/WeatherHandlerFullControl.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample5/Sample5Snippets.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample5/StudyTutorHandler.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample6/MathSolverHandler.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample6/MathSolverHandlerFullControl.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample6/Sample6Snippets.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/resources/log4j2.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/Dockerfile create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/applicationinsights.json create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/docker-compose.yml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/pom.xml create mode 100755 sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/run-sample.sh create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/src/main/java/com/microsoft/agentserver/EchoHandler.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/src/main/java/com/microsoft/agentserver/Main.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/src/main/java/com/microsoft/agentserver/WeatherHandler.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests/pom.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests/src/test/java/UpstreamOpenAISmokeTest.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests/src/test/java/financial/FinancialAgentSmokeTest.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j-bom/pom.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/pom.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/Langchain4jResponsesHandler.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/SupervisorAgentWithMemory.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/noop/NOOPSupervisorAgent.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/noop/NOOPUntypedAgent.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/resources/META-INF/beans.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/test/java/com/microsoft/agentserver/api/langchain4j/OpenAIClientTest.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/pom.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/ApiExceptionMapper.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/HealthResource.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/InboundRequestLoggingFilter.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/ObjectMapperProvider.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/PlatformHeaderResponseFilter.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/ResponsesResource.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/RoutingFilter.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/SseResponseFilter.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/resources/META-INF/beans.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/pom.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/java/com/microsoft/agentserver/server/jersey/ExceptionMappers.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/java/com/microsoft/agentserver/server/jersey/Filters.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/java/com/microsoft/agentserver/server/jersey/JerseyAgentServerAdaptorService.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/resources/META-INF/beans.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/test/java/com/microsoft/agentserver/server/jersey/JerseyServerIntegrationTest.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/pom.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/Filters.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/GlobalExceptionHandler.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/HealthController.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/InboundRequestLoggingFilter.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/ObjectMapperConfig.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/PlatformHeaderFilter.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/ResponsesController.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/SpringAgentServerAdaptorService.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/SseHeaderFilter.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/StreamRoutingFilter.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/Dockerfile create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/applicationinsights.json create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/docker-compose.yml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/pom.xml create mode 100755 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/run-sample.sh create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/run.sh create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/GeneralAgent.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/Init.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/Main.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/MathsAgent.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/MathsTools.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/resources/META-INF/beans.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/resources/application.properties create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/test/java/JerseyAgentServerService.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/test/java/Main.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/Dockerfile create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/applicationinsights.json create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/docker-compose.yml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/pom.xml create mode 100755 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/run-sample.sh create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/run.sh create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/GeneralAgent.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/Init.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/Main.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/MathsAgent.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/MathsTools.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/resources/application.properties create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/test/java/Main.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/test/java/SpringAgentServerService.java create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/Dockerfile create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/applicationinsights.json create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/docker-compose.yml create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/pom.xml create mode 100755 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/run-sample.sh create mode 100755 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/scripts/run.sh create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/java/com/microsoft/agentserver/sample/financial/jersey/Main.java create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/java/com/microsoft/agentserver/sample/financial/jersey/OpenTelemetryRequestFilter.java create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/resources/log4j2.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/pom.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/AccountInfoAgent.java create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/AccountingAgent.java create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/BankTool.java create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/CreditAgent.java create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/ExchangeAgent.java create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/ExchangeTool.java create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/WithdrawAgent.java create mode 100644 sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/resources/META-INF/beans.xml create mode 100644 sdk/agentserver/docs/developer-guide.md create mode 100644 sdk/agentserver/docs/end-user-guide.md create mode 100755 sdk/agentserver/mvnw create mode 100644 sdk/agentserver/pom.xml diff --git a/sdk/agentserver/README.md b/sdk/agentserver/README.md new file mode 100644 index 000000000000..dd9eb0604d1e --- /dev/null +++ b/sdk/agentserver/README.md @@ -0,0 +1,304 @@ +# Java Agent Server Adapter + +A Java adapter for building agent servers that implement the OpenAI Responses API. The project provides a framework +for creating agents that can be deployed as containers and interact with Azure AI Foundry. + +## Project Structure + +| Module | Description | +|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------| +| `java-agent-server-api` | Core API — defines `ResponseHandler`, `ResponseContext`, streaming primitives, and the OpenAI Responses model types. | +| `java-agent-server-jersey` | Jersey/Grizzly HTTP server adapter that exposes a `AgentServerResponsesApi` as a REST endpoint on port **8088**. | +| `java-agent-server-langchain4j` | Integration layer for [LangChain4j](https://docs.langchain4j.dev/) agents, mapping them into the Agent Server Responses API. | +| `java-agent-server-langchain4j-bom` | Bill-of-Materials (BOM) for the LangChain4j integration. | +| `agent-servers-samples/` | Sample agents (see [Samples](#samples) below). | + +## Prerequisites + +* **Java 21+** (the project targets Java 21) +* **Docker** & **Docker Compose** (for containerized runs) +* No local Maven installation is needed — the included **Maven Wrapper** (`./mvnw`) downloads the correct version + automatically. + +## Building + +From the `java_adapter/` directory, build all modules: + +```bash +./mvnw clean package -DskipTests +``` + +To run the unit tests: + +```bash +./mvnw clean verify +``` + +## Samples + +### Echo Sample + +A minimal agent that echoes the user's input back. It does not call any LLM and requires no API keys — useful for +verifying the adapter wiring and HTTP/SSE lifecycle. + +**Run with the provided script:** + +```bash +cd agent-servers-samples/echo-sample +chmod +x run-sample.sh +./run-sample.sh +``` + +The script will: + +1. Build the entire project with `../../mvnw clean package -DskipTests`. +2. Build and start the Docker container (port **8088**). +3. Send a test request and print the JSON response. +4. Tear down the container. + +**Or run each step manually:** + +```bash +# 1. Build +../../mvnw clean package -DskipTests + +# 2. Start the container +docker-compose up -d --build + +# 3. Test — synchronous JSON response +curl -X POST http://localhost:8088/responses \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Echo this back to me.", + "model": "gpt-4o" + }' | json_pp + +# 4. Test — streaming SSE response +curl -X POST http://localhost:8088/responses \ + -H "Accept: text/event-stream" \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Echo this back to me.", + "model": "gpt-4o" + }' --no-buffer + +# 5. Stop +docker-compose down +``` + +--- + +### LangChain4j Sample (`agent-servers-langchain4j-sample`) + +An agent powered by LangChain4j and Azure OpenAI that solves simple maths queries using tool-calling (add, subtract, +multiply, hash). + +#### Configure environment variables + +Create or edit the `.env` file at `src/adapter/java/.env` (three levels above this directory): + +```dotenv +USE_AZURE_CLIENT=true + +AZURE_DEPLOYMENT_NAME=gpt-4o +AZURE_ENDPOINT=https://.openai.azure.com/ +AZURE_API_KEY= +``` + +#### Run with the provided script + +```bash +cd agent-servers-samples/agent-servers-langchain4j-sample +chmod +x run-sample.sh +./run-sample.sh +``` + +The script will: + +1. Build the entire project with `../../mvnw package -DskipTests`. +2. Load environment variables from `../../../.env`. +3. Build and start the Docker container (port **8088**), injecting the Azure OpenAI configuration. +4. Send a test request (`"What is 12 + 13"`) and print the JSON response. +5. Tear down the container. + +**Or run each step manually:** + +```bash +# 1. Build +../../mvnw package -DskipTests + +# 2. Export env vars +set -a && source ../../../.env && set +a + +# 3. Start the container +docker-compose up -d --build + +# 4. Wait for startup +sleep 10 + +# 5. Test +curl -X POST http://localhost:8088/responses \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "input": "What is 12 + 13", + "model": "gpt-4o" + }' | json_pp + +# 6. Stop +docker-compose down +``` + +## Running without Docker + +You can also run the fat-JAR directly after building: + +```bash +# Echo sample +java -jar agent-servers-samples/echo-sample/target/echo-sample-1.0.0-SNAPSHOT-jar-with-dependencies.jar + +# LangChain4j sample (requires env vars to be set) +export AZURE_DEPLOYMENT_NAME=gpt-4o +export AZURE_API_KEY= +export AZURE_ENDPOINT=https://.openai.azure.com/ +java -jar agent-servers-samples/agent-servers-langchain4j-sample/target/agent-servers-langchain4j-sample-1.0.0-SNAPSHOT-jar-with-dependencies.jar +``` + +The server starts on `http://localhost:8088`. + +## Deploying to Azure AI Foundry + +Refer to the `deploy.sh` script in the echo-sample for an example of registering a agent server with Azure AI Foundry +using `az rest`: + +```bash +cd agent-servers-samples/echo-sample +# Edit deploy.sh to set your FOUNDRY_ACCOUNT, PROJECT, IMAGE, etc. +./deploy.sh +``` + +## ADC Egress Proxy CA Certificate (hosted runtime) + +When an agent runs inside the Azure AI Foundry vNext ("ADC") hosting environment, traffic to +the Foundry **project endpoint** (`https://.services.ai.azure.com/...` — which fronts +Azure OpenAI, the Responses storage API, MSI token exchange, etc.) is routed through a +TLS-terminating egress proxy whose CA certificate is mounted into the container at +`/etc/ssl/certs/adc-egress-proxy-ca.crt`. The JVM does not trust this CA by default, so calls +fail with a `PKIX path building failed` / TLS handshake error. + +The proxy is **selective**, it only intercepts `*.services.ai.azure.com`. + +### Recommended setup + +Bootstrap from your application's `main()` with the zero-argument +`installAdcEgressProxyCertificate()`: + +```java +import com.microsoft.agentserver.api.TrustStoreInstaller; + +public final class Main { + public static void main(String[] args) throws Exception { + // Installs the ADC egress proxy CA and scopes its trust to *.services.ai.azure.com only. + // Safe to call unconditionally — no-op when the mount path is absent (local dev). + TrustStoreInstaller.installAdcEgressProxyCertificate(); + + // ... rest of startup ... + } +} +``` + +This applies a full install with the default host-scoping predicate +(`*.services.ai.azure.com`) and logs through the class-static SLF4J logger. + +If you want the install/audit lines to flow through your application's own logger (so they +appear in the same log stream as the rest of your output, e.g. Application Insights), use +the one-arg overload: + +```java +TrustStoreInstaller.installAdcEgressProxyCertificate(LOGGER); +``` + +### Important constraints + +* **Call from `main()`, not a static initializer block.** Logging frameworks (e.g. the + Application Insights Java agent) attach their appenders during early JVM startup; messages + emitted from a static block can be dropped before the appender is wired up, hiding the + install confirmation lines. +* **Call before any HTTPS client is created.** Once `SSLContext.getDefault()` has been + initialized, the system-property switch has no effect for the current JVM. +* **Requires `keytool` / a full JDK image at runtime.** Sample Dockerfiles use + `eclipse-temurin:25-jdk` (not `-jre`) for this reason. +* **For application-logger output, use the one-arg overload.** By default the install + audit lines go through the class-static SLF4J logger (`TrustStoreInstaller`); pass your + own logger to route them into your application's log stream (e.g. Application Insights). +* For an arbitrary (non-ADC) proxy CA, use + `TrustStoreInstaller.installProxyCertificate(logger, hostPredicate, certPath, aliasPrefix)`. + Pass `null` for `hostPredicate` to skip the host-scoping layer. + +### Alternative: container `run.sh` entrypoint + +If you prefer not to modify your application's `main()`, the same effect can be achieved +externally by having the container entrypoint import the cert via `keytool` before launching +the JVM. Each sample ships a `run.sh` of this shape (copied into the image and used as the +Dockerfile `ENTRYPOINT`): + +```sh +#!/bin/sh +ADC_CERT="/etc/ssl/certs/adc-egress-proxy-ca.crt" +if [ -f "$ADC_CERT" ]; then + echo "Importing ADC egress proxy CA cert into Java truststore..." + cat "$ADC_CERT" >> /etc/ssl/certs/ca-certificates.crt 2>/dev/null || true + JAVA_CACERTS="$JAVA_HOME/lib/security/cacerts" + if [ -f "$JAVA_CACERTS" ]; then + keytool -importcert -noprompt -trustcacerts \ + -alias adc-egress-proxy \ + -file "$ADC_CERT" \ + -keystore "$JAVA_CACERTS" \ + -storepass changeit 2>/dev/null || true + echo "ADC CA cert imported into Java truststore." + else + echo "WARNING: Java cacerts not found at $JAVA_CACERTS" + fi +else + echo "No ADC egress proxy CA cert found (not running in vNext)." +fi + +exec java \ + ${APPLICATIONINSIGHTS_CONNECTION_STRING:+-javaagent:/app/applicationinsights-agent.jar} \ + -jar /app/app.jar +``` + +Wire it into the Dockerfile: + +```dockerfile +FROM eclipse-temurin:25-jdk +COPY target/app.jar /app/app.jar +COPY run.sh /app/run.sh +RUN chmod +x /app/run.sh +ENTRYPOINT ["/app/run.sh"] +``` + +Trade-offs vs. the in-process `TrustStoreInstaller` call: + +| Aspect | `run.sh` (keytool) | `TrustStoreInstaller` (Java) | +|------------------------------------------|---------------------------------|-----------------------------------------| +| Mutates JDK `cacerts` in the image layer | Yes (in-place) | No (writes a temp keystore) | +| Works on JRE-only images | No — needs `keytool` (full JDK) | No — also needs `keytool`-capable JDK | +| Visible in application logs | Only via stdout (`echo`) | Via supplied SLF4J logger + AppInsights | +| Local-dev no-op when cert is absent | Yes | Yes | +| Requires touching `main()` | No | Yes (one line) | +| Survives container restart (same image) | Yes | Re-runs each JVM start | + +Pick **one** approach, not both. The Java call is preferred when you want the install +confirmation to flow through your structured logger (and thence to Application Insights / +log aggregation); the shell script is preferred when you want zero application-code changes +or need the cert trusted by non-Java tools in the same container. + +## API Endpoints + +| Method | Path | Accept Header | Description | +|--------|--------------|---------------------|-----------------------------------| +| `POST` | `/responses` | `application/json` | Returns a complete JSON response. | +| `POST` | `/responses` | `text/event-stream` | Returns a streaming SSE response. | + diff --git a/sdk/agentserver/azure-agentserver-api/pom.xml b/sdk/agentserver/azure-agentserver-api/pom.xml new file mode 100644 index 000000000000..a392aa99822e --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/pom.xml @@ -0,0 +1,83 @@ + + 4.0.0 + + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + azure-agentserver-api + jar + azure-agentserver-api + + Core API library for the Azure AI Foundry Agent Server Server + + + + org.slf4j + slf4j-api + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.openai + openai-java-core + + + com.azure + azure-identity + + + com.azure + azure-core + + + io.opentelemetry + opentelemetry-api + + + + + io.netty + netty-codec-http2 + + + io.netty + netty-resolver-dns + + + io.netty + netty-handler-proxy + + + + + org.junit.jupiter + junit-jupiter + test + + + org.slf4j + slf4j-simple + test + + + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentReference.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentReference.java new file mode 100644 index 000000000000..c68c5b6474a2 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentReference.java @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a reference to an agent in the Foundry platform. + * Contains identifying information such as name, version, and label. + */ +public record AgentReference(AgentReferenceType type, String name, String version, String label) { + @JsonCreator + @JsonIgnoreProperties(ignoreUnknown = true) + public AgentReference( + @JsonProperty("type") AgentReferenceType type, + @JsonProperty("name") String name, + @JsonProperty("version") String version, + @JsonProperty("label") String label) { + this.type = type; + this.name = name; + this.version = version; + this.label = label; + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentReferenceType.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentReferenceType.java new file mode 100644 index 000000000000..d86ad7965da2 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentReferenceType.java @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.fasterxml.jackson.annotation.JsonCreator; + +/** + * Enumeration of agent reference types used in the agent server protocol. + */ +public enum AgentReferenceType { + + AGENT_REFERENCE("agent_reference"); + + private final String value; + + AgentReferenceType(String value) { + this.value = value; + } + + @JsonCreator + public static AgentReferenceType fromValue(String value) { + for (AgentReferenceType b : AgentReferenceType.values()) { + if (b.value.equals(value)) { + return b; + } + } + throw new IllegalArgumentException("Unexpected value '" + value + "'"); + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerCreateResponse.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerCreateResponse.java new file mode 100644 index 000000000000..a9b12f3e8515 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerCreateResponse.java @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseInputItem; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.List; + +/** + * Represents an incoming create-response request in the agent server protocol. + *

+ * Wraps the standard OpenAI {@link ResponseCreateParams.Body} together with an + * optional {@link AgentReference} identifying the target agent. Uses a custom + * Jackson deserializer to handle the Foundry platform's request format, including + * fallback handling for input items missing the {@code "type"} discriminator. + */ +@JsonDeserialize(using = AgentServerCreateResponse.AgentServerResponseCreateDeserializer.class) +public record AgentServerCreateResponse(AgentReference agent, ResponseCreateParams.Body responseCreateParams) { + private static final Logger LOGGER = LoggerFactory.getLogger(AgentServerCreateResponse.class); + + /** + * Extracts the input text from the request. + * If the input is a simple text string, returns it directly. + * Returns an empty string if no text input is present. + * + * @return the input text, or empty string + */ + public String inputText() { + if (responseCreateParams == null) { + return ""; + } + return responseCreateParams.input() + .filter(ResponseCreateParams.Input::isText) + .map(ResponseCreateParams.Input::asText) + .orElse(""); + } + + public static class AgentServerResponseCreateDeserializer extends JsonDeserializer { + + @Override + public AgentServerCreateResponse deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + + try { + JsonNode node = p.getCodec().readTree(p); + + AgentReference agent = null; + // "agent_reference" is the canonical Foundry field name and takes + // precedence over the legacy "agent" alias when both are present. + if (node.has("agent_reference")) { + agent = p.getCodec().treeToValue(node.get("agent_reference"), AgentReference.class); + } else if (node.has("agent")) { + agent = p.getCodec().treeToValue(node.get("agent"), AgentReference.class); + } + // First pass: deserialize as-is + ResponseCreateParams.Body createResponse = p.getCodec().treeToValue(node, ResponseCreateParams.Body.class); + + createResponse = fixMessageParsingOnMissingTypes(p, createResponse, node); + + return new AgentServerCreateResponse(agent, createResponse); + } catch (Exception e) { + LOGGER.debug("Failed to deserialize AgentServerCreateResponse", e); + throw e; + } + } + + private ResponseCreateParams.Body fixMessageParsingOnMissingTypes(JsonParser p, ResponseCreateParams.Body createResponse, JsonNode node) throws JsonProcessingException { + // Detect input items that fell back to raw JSON (missing "type" discriminator) + // and reparse with "type": "message" injected so they become EasyInputMessages. + if (createResponse.input().isPresent() + && !createResponse.input().get().isText()) { + List items = createResponse.input().get().asResponse(); + boolean needsReparse = false; + for (ResponseInputItem item : items) { + if (!item.isEasyInputMessage() && !item.isMessage() + && !item.isFunctionCall() && !item.isFunctionCallOutput() + && !item.isComputerCall() && !item.isComputerCallOutput() + && !item.isItemReference() && !item.isCodeInterpreterCall() + && item._json().isPresent()) { + needsReparse = true; + break; + } + } + + if (needsReparse && node.has("input") && node.get("input").isArray()) { + ArrayNode inputArray = (ArrayNode) node.get("input"); + boolean modified = false; + for (int i = 0; i < inputArray.size(); i++) { + JsonNode element = inputArray.get(i); + if (element.isObject() && !element.has("type") + && element.has("role") && element.has("content")) { + ((ObjectNode) element).put("type", "message"); + modified = true; + } + } + if (modified) { + LOGGER.debug("Injected 'type: message' into input items missing type discriminator"); + createResponse = p.getCodec().treeToValue(node, ResponseCreateParams.Body.class); + } + } + } + return createResponse; + } + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseContext.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseContext.java new file mode 100644 index 000000000000..f08ba31b8568 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseContext.java @@ -0,0 +1,355 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.fasterxml.jackson.databind.JsonNode; +import com.microsoft.agentserver.api.implementation.IdGenerator; +import com.microsoft.agentserver.api.implementation.ItemConversion; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseInputItem; +import com.openai.models.responses.ResponseItem; +import com.openai.models.responses.ResponseOutputItem; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Enhanced implementation of {@link ResponseContext} that resolves input items + * and conversation history from the request, using lazy-cached async resolution. + * Inline items are converted via {@link ItemConversion}; item references are + * resolved via {@link ResponsesProvider#getItemsAsync}. + *

+ * Accepts either {@link ResponseCreateParams} or {@link ResponseCreateParams.Body} + * as input — both expose the same API surface ({@code input()}, + * {@code previousResponseId()}, {@code conversation()}, etc.). + */ +final class AgentServerResponseContext implements ResponseContext { + + /** + * Default maximum number of history items to fetch. + */ + static final int DEFAULT_FETCH_HISTORY_COUNT = 100; + + private final String responseId; + private final ResponsesProvider provider; + private final ResponseCreateParams.Body request; + private final JsonNode rawBody; + private final int historyLimit; + private final IsolationContext isolation; + private final Map clientHeaders; + private final Map queryParameters; + private final String requestId; + private final String sessionId; + // Lazy-cached async results (AtomicReference for thread-safe single-init) + private final AtomicReference>> inputItemsRef = + new AtomicReference<>(); + private final AtomicReference>> historyItemIdsRef = + new AtomicReference<>(); + private final AtomicReference>> historyRef = + new AtomicReference<>(); + private volatile boolean shutdownRequested; + private volatile boolean cancelled; + + /** + * Initializes a new instance of {@code AgentServerResponseContext}. + *

+ * Use {@link ResponseContext#builder()} to construct instances via the fluent builder API. + * + * @param responseId the unique response identifier. + * @param provider the responses provider for resolving item references and history. + * @param request the create-response request body containing input items. + * @param rawBody the full raw JSON request body, or {@code null} if not available. + * @param historyLimit maximum number of history items to fetch, or {@code null} for the default (100). + * @param isolation the isolation context, or {@code null} for {@link IsolationContext#EMPTY}. + * @param clientHeaders the forwarded client headers, or {@code null} for empty. + * @param queryParameters the query parameters, or {@code null} for empty. + * @param requestId the request ID for correlation, or {@code null}. + * @param sessionId the resolved session ID per {@code SessionIdResolver}, or {@code null}. + */ + AgentServerResponseContext( + String responseId, + ResponsesProvider provider, + ResponseCreateParams.Body request, + JsonNode rawBody, + Integer historyLimit, + IsolationContext isolation, + Map clientHeaders, + Map queryParameters, + String requestId, + String sessionId) { + this.responseId = Objects.requireNonNull(responseId, "responseId must not be null"); + this.provider = Objects.requireNonNull(provider, "provider must not be null"); + this.request = Objects.requireNonNull(request, "request must not be null"); + this.rawBody = rawBody; + this.historyLimit = historyLimit != null ? historyLimit : DEFAULT_FETCH_HISTORY_COUNT; + this.isolation = isolation != null ? isolation : IsolationContext.EMPTY; + this.clientHeaders = clientHeaders != null ? clientHeaders : Collections.emptyMap(); + this.queryParameters = queryParameters != null ? queryParameters : Collections.emptyMap(); + this.requestId = requestId; + this.sessionId = sessionId; + } + + /** + * Backward-compatible constructor without platform headers. + */ + AgentServerResponseContext( + String responseId, + ResponsesProvider provider, + ResponseCreateParams.Body request, + JsonNode rawBody, + Integer historyLimit) { + this(responseId, provider, request, rawBody, historyLimit, null, null, null, null, null); + } + + /** + * Converts a {@link ResponseItem} back to a {@link ResponseOutputItem} for input resolution. + *

+ * Note: {@code FunctionCall} items cannot be converted because + * {@link ResponseItem#asFunctionCall()} returns {@code ResponseFunctionToolCallItem} + * (input variant) while {@link ResponseOutputItem#ofFunctionCall} requires + * {@code ResponseFunctionToolCall} (output variant). + */ + private static ResponseOutputItem toOutputItem(ResponseItem item) { + if (item.isResponseOutputMessage()) { + return ResponseOutputItem.ofMessage(item.asResponseOutputMessage()); + } + if (item.isFileSearchCall()) { + return ResponseOutputItem.ofFileSearchCall(item.asFileSearchCall()); + } + if (item.isWebSearchCall()) { + return ResponseOutputItem.ofWebSearchCall(item.asWebSearchCall()); + } + if (item.isComputerCall()) { + return ResponseOutputItem.ofComputerCall(item.asComputerCall()); + } + if (item.isReasoning()) { + return ResponseOutputItem.ofReasoning(item.asReasoning()); + } + if (item.isCodeInterpreterCall()) { + return ResponseOutputItem.ofCodeInterpreterCall(item.asCodeInterpreterCall()); + } + if (item.isShellCall()) { + return ResponseOutputItem.ofShellCall(item.asShellCall()); + } + if (item.isShellCallOutput()) { + return ResponseOutputItem.ofShellCallOutput(item.asShellCallOutput()); + } + if (item.isApplyPatchCall()) { + return ResponseOutputItem.ofApplyPatchCall(item.asApplyPatchCall()); + } + if (item.isApplyPatchCallOutput()) { + return ResponseOutputItem.ofApplyPatchCallOutput(item.asApplyPatchCallOutput()); + } + return null; + } + + /** + * Lazily initializes and caches a {@link CompletableFuture} in the given + * {@link AtomicReference}, ensuring at most one computation. + */ + private static CompletableFuture getOrInit( + AtomicReference> ref, + java.util.function.Supplier> supplier) { + CompletableFuture existing = ref.get(); + if (existing != null) { + return existing; + } + CompletableFuture created = supplier.get(); + if (ref.compareAndSet(null, created)) { + return created; + } + return ref.get(); + } + + private static boolean isNullOrEmpty(String s) { + return s == null || s.isEmpty(); + } + + @Override + public String getResponseId() { + return responseId; + } + + @Override + public boolean isShutdownRequested() { + return shutdownRequested; + } + + /** + * Sets whether the server is shutting down. + * This is called by the hosting infrastructure, not by handlers. + */ + public void setShutdownRequested(boolean shutdownRequested) { + this.shutdownRequested = shutdownRequested; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void cancel() { + this.cancelled = true; + } + + @Override + public JsonNode getRawBody() { + return rawBody; + } + + @Override + public IsolationContext getIsolation() { + return isolation; + } + + @Override + public Map getClientHeaders() { + return clientHeaders; + } + + @Override + public Map getQueryParameters() { + return queryParameters; + } + + @Override + public String getRequestId() { + return requestId; + } + + /** + * Package-private accessor for the original request body (needed by API helpers). + */ + ResponseCreateParams.Body getRequestBody() { + return request; + } + + @Override + public String getSessionId() { + return sessionId; + } + + // ── Private resolution methods ────────────────────────────── + + @Override + public CompletableFuture> getInputItemsAsync() { + return getOrInit(inputItemsRef, this::resolveInputItemsAsync); + } + + @Override + public CompletableFuture> getHistoryAsync() { + return getOrInit(historyRef, this::resolveHistoryAsync); + } + + /** + * Gets the cached history item IDs. Used by the orchestrator to pass IDs + * to the provider without duplicating storage. + * + * @return a future containing the resolved history item IDs. + */ + public CompletableFuture> getHistoryItemIdsAsync() { + return getOrInit(historyItemIdsRef, this::resolveHistoryItemIdsAsync); + } + + private CompletableFuture> resolveInputItemsAsync() { + Optional inputOpt = request.input(); + if (inputOpt.isEmpty()) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + ResponseCreateParams.Input input = inputOpt.get(); + + // If the input is a plain text string, there are no structured items to resolve + if (input.isText()) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + List items = input.asResponse(); + if (items.isEmpty()) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + IdGenerator idGen = new IdGenerator(IdGenerator.extractPartitionKey(responseId)); + + // Separate inline items from item references + List results = new ArrayList<>(items.size()); + List referenceIds = new ArrayList<>(); + List referencePositions = new ArrayList<>(); + + for (ResponseInputItem item : items) { + if (item.isItemReference()) { + referenceIds.add(item.asItemReference().id()); + referencePositions.add(results.size()); + results.add(null); // placeholder — index matches results.size() + } else { + ResponseOutputItem output = ItemConversion.toOutputItem(item, idGen); + if (output != null) { + results.add(output); + } + // Non-convertible, non-reference items are silently skipped + } + } + + // If no references, return the inline results immediately + if (referenceIds.isEmpty()) { + return CompletableFuture.completedFuture(Collections.unmodifiableList(results)); + } + + // Batch-resolve references via the provider + return provider.getItemsAsync(referenceIds) + .thenApply(resolved -> { + for (int i = 0; i < referencePositions.size(); i++) { + int pos = referencePositions.get(i); + if (i < resolved.size() && resolved.get(i) != null) { + ResponseOutputItem converted = toOutputItem(resolved.get(i)); + if (converted != null) { + results.set(pos, converted); + } + } + } + // Remove unresolved placeholders (nulls remaining from failed references) + return results.stream() + .filter(Objects::nonNull) + .toList(); + }); + } + + // ── Utility ───────────────────────────────────────────────── + + private CompletableFuture> resolveHistoryItemIdsAsync() { + String previousResponseId = request.previousResponseId().orElse(null); + String conversationId = request.conversation() + .flatMap(conv -> conv.isId() ? Optional.of(conv.asId()) : Optional.empty()) + .orElse(null); + + if (isNullOrEmpty(previousResponseId) && isNullOrEmpty(conversationId)) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + return provider.getHistoryItemIdsAsync(previousResponseId, conversationId, historyLimit); + } + + private CompletableFuture> resolveHistoryAsync() { + return getHistoryItemIdsAsync() + .thenCompose(ids -> { + if (ids.isEmpty()) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + return provider.getItemsAsync(ids) + .thenApply(items -> items.stream() + .filter(Objects::nonNull) + .toList()); + }); + } +} + + + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseEventStream.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseEventStream.java new file mode 100644 index 000000000000..e5f50049efe5 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseEventStream.java @@ -0,0 +1,1192 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.microsoft.agentserver.api.implementation.IdGenerator; +import com.openai.core.JsonNull; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCodeInterpreterToolCall; +import com.openai.models.responses.ResponseCompletedEvent; +import com.openai.models.responses.ResponseContentPartAddedEvent; +import com.openai.models.responses.ResponseContentPartDoneEvent; +import com.openai.models.responses.ResponseCreatedEvent; +import com.openai.models.responses.ResponseCustomToolCall; +import com.openai.models.responses.ResponseError; +import com.openai.models.responses.ResponseFailedEvent; +import com.openai.models.responses.ResponseFileSearchToolCall; +import com.openai.models.responses.ResponseFunctionCallArgumentsDeltaEvent; +import com.openai.models.responses.ResponseFunctionCallArgumentsDoneEvent; +import com.openai.models.responses.ResponseFunctionToolCall; +import com.openai.models.responses.ResponseFunctionWebSearch; +import com.openai.models.responses.ResponseInProgressEvent; +import com.openai.models.responses.ResponseIncompleteEvent; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputItemAddedEvent; +import com.openai.models.responses.ResponseOutputItemDoneEvent; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseOutputText; +import com.openai.models.responses.ResponseQueuedEvent; +import com.openai.models.responses.ResponseReasoningItem; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ResponseStreamEvent; +import com.openai.models.responses.ResponseTextDeltaEvent; +import com.openai.models.responses.ResponseTextDoneEvent; +import com.openai.models.responses.ResponseUsage; +import com.openai.models.responses.ToolChoiceOptions; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Flow; +import java.util.concurrent.SubmissionPublisher; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +/** + * Fluent builder for constructing a streaming response event sequence. + *

+ * Manages a global sequence counter, output index counter, and an accumulated + * {@link Response} snapshot. Every {@code emit*()} method appends a {@link ResponseEvent} + * to the internal list and returns {@code this} for chaining. + *

+ * Nested output-item scopes use the {@link Consumer} callback pattern so that + * builder lifecycles are lexically scoped and automatically finalized. + *

+ * Call {@link #subscribe(Consumer, Consumer, Runnable)} to receive events + * in real-time as they are produced — including text deltas from deeply nested builders. + * Events are delivered via a {@link SubmissionPublisher} from the JDK's + * {@link Flow} reactive-streams API. + * + *

Threading model

+ *

+ * This class is designed for a single-writer pattern: all {@code emit*()} + * and {@code addOutput*()} methods must be called from a single thread (or with external + * serialization). The {@link #subscribe(Consumer, Consumer, Runnable)} callbacks are + * invoked by the {@link SubmissionPublisher}'s executor. Internal synchronization ensures + * safe hand-off between the writer and subscriber. + * + *

Example usage (synchronous)

+ *
{@code
+ * ResponseEventStream stream = ResponseEventStream.create(request, context)
+ *     .emitCreated()
+ *     .emitInProgress()
+ *     .addOutputMessage(msg -> msg
+ *         .emitAdded()
+ *         .addTextPart(text -> text
+ *             .emitAdded()
+ *             .emitDelta("Hello!")
+ *             .emitDone("Hello!"))
+ *         .emitDone())
+ *     .emitCompleted();
+ *
+ * List events = stream.getEvents();
+ * }
+ * + *

Example usage (reactive streaming)

+ *
{@code
+ * ResponseEventStream stream = ResponseEventStream.create(request, context);
+ * stream.subscribe(
+ *     event -> System.out.println(event.eventName()),
+ *     failure -> failure.printStackTrace(),
+ *     () -> System.out.println("Done"));
+ *
+ * // Build on another thread — subscribers see events immediately.
+ * // awaitSubscription() blocks until subscribe() has been called,
+ * // preventing events from being emitted before a subscriber is ready.
+ * executor.submit(() -> {
+ *     stream.awaitSubscription();
+ *     stream.emitCreated()
+ *         .emitInProgress()
+ *         .addOutputMessage(msg -> msg
+ *             .emitAdded()
+ *             .addTextPart(text -> text
+ *                 .emitAdded()
+ *                 .emitDelta("Hello!")
+ *                 .emitDone("Hello!"))
+ *             .emitDone())
+ *         .emitCompleted();
+ * });
+ * }
+ */ +final class AgentServerResponseEventStream implements ResponseEventStream { + + private final IdGenerator idGenerator; + private final String responseId; + // Accumulated events and output items. + private final CopyOnWriteArrayList events = new CopyOnWriteArrayList<>(); + // Concurrent map because trackCompletedOutputItem() may be called from nested + // builders or forwardUpstream() while a subscriber is consuming events on a + // different thread (e.g., via the SubmissionPublisher executor). + private final Map outputItems = new java.util.concurrent.ConcurrentSkipListMap<>(); + // JDK reactive publisher for push-based event delivery. + // Synchronous executor (Runnable::run) ensures events are delivered inline + // on the writer thread, matching the original Multi emitter semantics. + private final SubmissionPublisher publisher = + new SubmissionPublisher<>(Runnable::run, Flow.defaultBufferSize()); + private final CountDownLatch subscriptionLatch = new CountDownLatch(1); + private final AtomicLong sequenceNumber = new AtomicLong(); + private final AtomicLong outputIndex = new AtomicLong(); + private final AtomicBoolean terminated = new AtomicBoolean(); + // Mutable response builder – rebuilt into an immutable snapshot per event. + // The OpenAI SDK builder is mutable (setters return `this`), but we reassign + // defensively so the code also works if the builder were ever replaced with + // an immutable variant. + // THREADING: all reads/writes of responseBuilder MUST hold builderLock. + private Response.Builder responseBuilder; + private final Object builderLock = new Object(); + + // ── Construction ────────────────────────────────────────── + + private AgentServerResponseEventStream(AgentServerCreateResponse request, ResponseContext context) { + Objects.requireNonNull(context, "context must not be null"); + Objects.requireNonNull(request, "request must not be null"); + + this.idGenerator = new IdGenerator(IdGenerator.extractPartitionKey(context.getResponseId())); + this.responseId = context.getResponseId(); + + String conversationId = request.responseCreateParams().conversation() + .flatMap(conv -> { + if (conv.isId()) { + return Optional.of(conv.asId()); + } + if (conv.isResponseConversationParam()) { + return Optional.of(conv.asResponseConversationParam().id()); + } + return Optional.empty(); + }) + .orElse(null); + + this.responseBuilder = Response.builder() + .id(context.getResponseId()) + .createdAt(System.currentTimeMillis() / 1000.0) + .status(ResponseStatus.QUEUED) + .error(Optional.empty()) + .incompleteDetails(Optional.empty()) + .instructions(Optional.empty()) + .metadata(Optional.empty()) + .parallelToolCalls(false) + .temperature(Optional.empty()) + .toolChoice(ToolChoiceOptions.AUTO) + .tools(List.of()) + .topP(Optional.empty()) + .output(List.of()) + .previousResponseId(request.responseCreateParams().previousResponseId().orElse(null)) + .background(request.responseCreateParams().background().orElse(null)) + .putAdditionalProperty("agent_id", new JsonNull()); + + if (request.responseCreateParams().model().isPresent()) { + request.responseCreateParams().model().ifPresent(this.responseBuilder::model); + } else { + String modelName = FoundryEnvironment.AGENT_NAME != null + ? FoundryEnvironment.AGENT_NAME : "unknown-agent"; + this.responseBuilder.model(modelName); + } + + if (conversationId != null) { + this.responseBuilder = this.responseBuilder.conversation( + Response.Conversation.builder().id(conversationId).build()); + } + } + + /** + * Creates a new event stream from a request and context. + */ + public static ResponseEventStream create(ResponseContext context, AgentServerCreateResponse request) { + return new AgentServerResponseEventStream(request, context); + } + + // ── Results ─────────────────────────────────────────────── + + /** + * Resolves the SSE event name for a given {@link ResponseStreamEvent}. + * Returns {@code null} for unrecognized or lifecycle events. + */ + private static String resolveEventName(ResponseStreamEvent event) { + if (event.isOutputItemAdded()) return "response.output_item.added"; + if (event.isOutputItemDone()) return "response.output_item.done"; + if (event.isOutputTextDelta()) return "response.output_text.delta"; + if (event.isOutputTextDone()) return "response.output_text.done"; + if (event.isContentPartAdded()) return "response.content_part.added"; + if (event.isContentPartDone()) return "response.content_part.done"; + if (event.isFunctionCallArgumentsDelta()) return "response.function_call_arguments.delta"; + if (event.isFunctionCallArgumentsDone()) return "response.function_call_arguments.done"; + // Add more as needed for reasoning, file search, etc. + return null; + } + + /** + * Creates a default zero-usage object with all required fields populated. + * The OpenAI SDK requires {@code inputTokensDetails} and {@code outputTokensDetails} + * even when usage is not tracked. + */ + private static ResponseUsage defaultUsage() { + return ResponseUsage.builder() + .inputTokens(0) + .outputTokens(0) + .totalTokens(0) + .inputTokensDetails(ResponseUsage.InputTokensDetails.builder() + .cachedTokens(0) + .build()) + .outputTokensDetails(ResponseUsage.OutputTokensDetails.builder() + .reasoningTokens(0) + .build()) + .build(); + } + + // ── Reactive Stream ─────────────────────────────────────── + + /** + * Returns an unmodifiable view of all events emitted so far. + */ + @Override + public List getEvents() { + return Collections.unmodifiableList(events); + } + + /** + * Returns a snapshot of the current {@link Response} being constructed. + */ + @Override + public Response getResponse() { + synchronized (builderLock) { + return responseBuilder.build(); + } + } + + /** + * Stamps the resolved {@code agent_session_id} onto every snapshot + * produced by this stream from this point forward. No-op for null/empty input. + * Package-private — invoked by {@link AgentServerResponsesApi} once the + * session ID is resolved for the current request. + */ + void setAgentSessionId(String sessionId) { + if (sessionId == null || sessionId.isEmpty()) { + return; + } + synchronized (builderLock) { + responseBuilder = responseBuilder.putAdditionalProperty( + "agent_session_id", com.openai.core.JsonValue.from(sessionId)); + } + } + + // ── Response Lifecycle Events ───────────────────────────── + + /** + * Subscribes to this event stream with the given callbacks. + *

+ * Every event produced by any builder — including deeply nested text deltas — + * is pushed to the {@code onEvent} callback immediately. If events have already + * been emitted before subscription, they are replayed first. + *

+ * Terminal events ({@code emitCompleted}, {@code emitFailed}, {@code emitIncomplete}) + * automatically invoke {@code onComplete}. + *

+ * This method is synchronized on the same monitor as {@link #addEvent} to + * guarantee that no events are lost between the replay loop and the + * {@link SubmissionPublisher} subscription. + * + * @param onEvent called for each {@link ResponseEvent} as it is produced + * @param onFailure called if the stream encounters an error + * @param onComplete called when the stream terminates normally + */ + @Override + public synchronized void subscribe(Consumer onEvent, Consumer onFailure, Runnable onComplete) { + // Replay events already emitted before subscription. + for (ResponseEvent event : events) { + onEvent.accept(event); + } + + // If the stream already terminated, signal completion immediately. + if (terminated.get()) { + subscriptionLatch.countDown(); + onComplete.run(); + return; + } + + // Wire up a Flow.Subscriber that delegates to the provided callbacks. + // Because addEvent() is also synchronized on `this`, no submit() can + // happen between the replay above and the registration below. + publisher.subscribe(new Flow.Subscriber<>() { + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(ResponseEvent event) { + onEvent.accept(event); + } + + @Override + public void onError(Throwable throwable) { + onFailure.accept(throwable); + } + + @Override + public void onComplete() { + onComplete.run(); + } + }); + + // Signal any threads waiting in awaitSubscription(). + subscriptionLatch.countDown(); + } + + /** + * Blocks the calling thread until {@link #subscribe} has been called, + * ensuring that no events are emitted before a subscriber is ready. + * Times out after 30 seconds to avoid deadlocks. + * + * @throws InterruptedException if the thread is interrupted while waiting + */ + @Override + public void awaitSubscription() throws InterruptedException { + if (!subscriptionLatch.await(30, TimeUnit.SECONDS)) { + throw new IllegalStateException( + "Timed out waiting for a subscriber to be registered. " + + "Ensure subscribe() is called within 30 seconds of stream creation."); + } + } + + /** + * Emits a {@code response.queued} event. Sets status to {@link ResponseStatus#QUEUED}. + */ + @Override + public ResponseEventStream emitQueued() { + synchronized (builderLock) { + responseBuilder = responseBuilder.status(ResponseStatus.QUEUED); + addEvent(new ResponseEvent("response.queued", + ResponseStreamEvent.ofQueued(new ResponseQueuedEvent.Builder() + .sequenceNumber(nextSequenceNumber()) + .response(snapshot()) + .build()))); + } + return this; + } + + /** + * Emits a {@code response.created} event with status {@link ResponseStatus#IN_PROGRESS}. + */ + @Override + public ResponseEventStream emitCreated() { + return emitCreated(ResponseStatus.IN_PROGRESS); + } + + /** + * Emits a {@code response.created} event with the given status. + */ + @Override + public ResponseEventStream emitCreated(ResponseStatus status) { + synchronized (builderLock) { + responseBuilder = responseBuilder.status(status); + } + addEvent(new ResponseEvent("response.created", + ResponseStreamEvent.ofCreated(new ResponseCreatedEvent.Builder() + .sequenceNumber(nextSequenceNumber()) + .response(snapshot()) + .build()))); + return this; + } + + /** + * Emits a {@code response.in_progress} event. Sets status to {@link ResponseStatus#IN_PROGRESS}. + */ + @Override + public ResponseEventStream emitInProgress() { + synchronized (builderLock) { + responseBuilder = responseBuilder.status(ResponseStatus.IN_PROGRESS); + } + addEvent(new ResponseEvent("response.in_progress", + ResponseStreamEvent.ofInProgress(new ResponseInProgressEvent.Builder() + .sequenceNumber(nextSequenceNumber()) + .response(snapshot()) + .build()))); + return this; + } + + /** + * Emits a {@code response.completed} event with no usage data. + */ + @Override + public ResponseEventStream emitCompleted() { + return emitCompleted(null); + } + + /** + * Emits a {@code response.completed} event with optional usage data. + * + * @throws IllegalStateException if a terminal event has already been emitted + */ + @Override + public ResponseEventStream emitCompleted(ResponseUsage usage) { + guardTerminal(); + // Default to zero-usage if none provided — clients may require the field. + ResponseUsage effectiveUsage = usage != null ? usage : defaultUsage(); + synchronized (builderLock) { + responseBuilder = responseBuilder + .status(ResponseStatus.COMPLETED) + .completedAt(System.currentTimeMillis() / 1000.0) + .output(collectOutputItems()) + .usage(effectiveUsage); + } + addEvent(new ResponseEvent("response.completed", + ResponseStreamEvent.ofCompleted(new ResponseCompletedEvent.Builder() + .sequenceNumber(nextSequenceNumber()) + .response(snapshot()) + .build()))); + completeMulti(); + return this; + } + + /** + * Emits a {@code response.failed} event with a server error. + */ + @Override + public ResponseEventStream emitFailed() { + return emitFailed(ResponseError.Code.SERVER_ERROR, "An internal server error occurred.", null); + } + + /** + * Emits a {@code response.failed} event with the given error. + */ + @Override + public ResponseEventStream emitFailed(ResponseError.Code code, String message) { + return emitFailed(code, message, null); + } + + /** + * Emits a {@code response.failed} event with the given error and optional usage data. + * + * @throws IllegalStateException if a terminal event has already been emitted + */ + @Override + public ResponseEventStream emitFailed(ResponseError.Code code, String message, ResponseUsage usage) { + guardTerminal(); + ResponseUsage effectiveUsage = usage != null ? usage : defaultUsage(); + synchronized (builderLock) { + responseBuilder = responseBuilder + .status(ResponseStatus.FAILED) + .completedAt(System.currentTimeMillis() / 1000.0) + .error(ResponseError.builder().code(code).message(message).build()) + .output(collectOutputItems()) + .usage(effectiveUsage); + } + addEvent(new ResponseEvent("response.failed", + ResponseStreamEvent.ofFailed(new ResponseFailedEvent.Builder() + .sequenceNumber(nextSequenceNumber()) + .response(snapshot()) + .build()))); + completeMulti(); + return this; + } + + /** + * Emits a {@code response.incomplete} event with no details. + */ + @Override + public ResponseEventStream emitIncomplete() { + return emitIncomplete(null, null); + } + + // ── Output Item Scope Factories (Consumer pattern) ──────── + + /** + * Emits a {@code response.incomplete} event with the given reason. + */ + @Override + public ResponseEventStream emitIncomplete(Response.IncompleteDetails.Reason reason) { + return emitIncomplete(reason, null); + } + + /** + * Emits a {@code response.incomplete} event with optional reason and usage. + * + * @throws IllegalStateException if a terminal event has already been emitted + */ + @Override + public ResponseEventStream emitIncomplete(Response.IncompleteDetails.Reason reason, ResponseUsage usage) { + guardTerminal(); + ResponseUsage effectiveUsage = usage != null ? usage : defaultUsage(); + synchronized (builderLock) { + responseBuilder = responseBuilder + .status(ResponseStatus.INCOMPLETE) + .completedAt(System.currentTimeMillis() / 1000.0) + .output(collectOutputItems()) + .usage(effectiveUsage); + } + if (reason != null) { + synchronized (builderLock) { + responseBuilder = responseBuilder.incompleteDetails( + Response.IncompleteDetails.builder().reason(reason).build()); + } + } + addEvent(new ResponseEvent("response.incomplete", + ResponseStreamEvent.ofIncomplete(new ResponseIncompleteEvent.Builder() + .sequenceNumber(nextSequenceNumber()) + .response(snapshot()) + .build()))); + completeMulti(); + return this; + } + + /** + * Adds a message output item. The consumer configures the message builder + * (emit added/done, add text parts, etc.) within a lexically scoped block. + */ + @Override + public ResponseEventStream addOutputMessage(Consumer config) { + long idx = outputIndex.getAndIncrement(); + String itemId = idGenerator.generateMessageItemId(); + OutputMessageBuilder builder = new OutputMessageBuilder(this, idx, itemId); + config.accept(builder); + return this; + } + + /** + * Adds a function call output item. The consumer configures the function call builder + * (emit added/done, emit argument deltas, etc.) within a lexically scoped block. + */ + @Override + public ResponseEventStream addOutputFunctionCall(Consumer config) { + long idx = outputIndex.getAndIncrement(); + String itemId = idGenerator.generateFunctionCallItemId(); + config.accept(new OutputFunctionCallBuilder(this, idx, itemId)); + return this; + } + + /** + * Adds a reasoning output item. + */ + @Override + public ResponseEventStream addOutputReasoningItem(Consumer> config) { + long idx = outputIndex.getAndIncrement(); + String itemId = idGenerator.generateReasoningItemId(); + config.accept(new OutputItemBuilder<>(this, idx, itemId)); + return this; + } + + /** + * Adds a file search call output item. + */ + @Override + public ResponseEventStream addOutputFileSearchCall(Consumer> config) { + long idx = outputIndex.getAndIncrement(); + String itemId = idGenerator.generateFileSearchCallItemId(); + config.accept(new OutputItemBuilder<>(this, idx, itemId)); + return this; + } + + /** + * Adds a web search call output item. + */ + @Override + public ResponseEventStream addOutputWebSearchCall(Consumer> config) { + long idx = outputIndex.getAndIncrement(); + String itemId = idGenerator.generateWebSearchCallItemId(); + config.accept(new OutputItemBuilder<>(this, idx, itemId)); + return this; + } + + /** + * Adds a code interpreter call output item. + */ + @Override + public ResponseEventStream addOutputCodeInterpreterCall( + Consumer> config) { + long idx = outputIndex.getAndIncrement(); + String itemId = idGenerator.generateCodeInterpreterCallItemId(); + config.accept(new OutputItemBuilder<>(this, idx, itemId)); + return this; + } + + /** + * Adds an image generation call output item. + */ + @Override + public ResponseEventStream addOutputImageGenCall( + Consumer> config) { + long idx = outputIndex.getAndIncrement(); + String itemId = idGenerator.generateImageGenCallItemId(); + config.accept(new OutputItemBuilder<>(this, idx, itemId)); + return this; + } + + /** + * Adds an MCP tool call output item. + */ + @Override + public ResponseEventStream addOutputMcpCall(Consumer> config) { + long idx = outputIndex.getAndIncrement(); + String itemId = idGenerator.generateMcpCallItemId(); + config.accept(new OutputItemBuilder<>(this, idx, itemId)); + return this; + } + + /** + * Adds an MCP list-tools output item. + */ + @Override + public ResponseEventStream addOutputMcpListTools( + Consumer> config) { + long idx = outputIndex.getAndIncrement(); + String itemId = idGenerator.generateMcpListToolsItemId(); + config.accept(new OutputItemBuilder<>(this, idx, itemId)); + return this; + } + + /** + * Adds a custom tool call output item. + */ + @Override + public ResponseEventStream addOutputCustomToolCall(Consumer> config) { + long idx = outputIndex.getAndIncrement(); + String itemId = idGenerator.generateCustomToolCallItemId(); + config.accept(new OutputItemBuilder<>(this, idx, itemId)); + return this; + } + + /** + * Adds a generic output item for types with no dedicated factory. + */ + @Override + public ResponseEventStream addOutputItem(String itemId, Consumer> config) { + Objects.requireNonNull(itemId, "itemId must not be null"); + long idx = outputIndex.getAndIncrement(); + config.accept(new OutputItemBuilder<>(this, idx, itemId)); + return this; + } + + /** + * Appends a pre-built event directly. For advanced / interop scenarios. + */ + @Override + public ResponseEventStream emit(ResponseEvent event) { + addEvent(event); + return this; + } + + // ── Internal helpers ────────────────────────────────────── + + /** + * Forwards an upstream OpenAI streaming response through this stream. + * All content events (output items, text deltas, function call arguments, + * reasoning, etc.) are forwarded directly. Upstream lifecycle events + * (created, in_progress, completed, queued) are skipped since this stream + * owns its own lifecycle. + *

+ * Throws {@link RuntimeException} if the upstream reports a failure event. + * + *

Example usage

+ *
{@code
+     * stream.emitCreated().emitInProgress();
+     * try (var upstream = client.responses().createStreaming(params)) {
+     *     stream.forwardUpstream(upstream.stream());
+     * }
+     * stream.emitCompleted();
+     * }
+ * + * @param upstreamEvents the stream of events from the upstream OpenAI client + * @return this stream for chaining + * @throws RuntimeException if the upstream reports a failure event + */ + @Override + public ResponseEventStream forwardUpstream(java.util.stream.Stream upstreamEvents) { + upstreamEvents.forEach(event -> { + // Skip upstream lifecycle events — we own the response envelope. + if (event.isCreated() || event.isInProgress() || event.isCompleted() || event.isQueued()) { + return; + } + + // Detect upstream failure. + if (event.isFailed()) { + throw new RuntimeException("Upstream request failed"); + } + + // Forward all content events (output_item.added, output_item.done, + // text deltas, function_call_arguments, content_part events, etc.) + // by determining the event name and re-emitting through our stream. + String eventName = resolveEventName(event); + if (eventName != null) { + // Track output items for the terminal completed event. + if (event.isOutputItemDone()) { + ResponseOutputItem item = event.asOutputItemDone().item(); + long idx = outputIndex.getAndIncrement(); + trackCompletedOutputItem(item, idx); + } + addEvent(new ResponseEvent(eventName, event)); + } + }); + return this; + } + + public long nextSequenceNumber() { + return sequenceNumber.getAndIncrement(); + } + + public synchronized void addEvent(ResponseEvent event) { + events.add(event); + publisher.submit(event); + } + + public void trackCompletedOutputItem(ResponseOutputItem item, long idx) { + outputItems.put(idx, item); + } + + /** + * Returns a copy of the accumulated output items in index order. + */ + private List collectOutputItems() { + return new ArrayList<>(outputItems.values()); + } + + private Response snapshot() { + synchronized (builderLock) { + return responseBuilder.build(); + } + } + + /** + * Atomically guards against calling a terminal method more than once. + * + * @throws IllegalStateException if a terminal event has already been emitted + */ + private void guardTerminal() { + if (!terminated.compareAndSet(false, true)) { + throw new IllegalStateException( + "Stream has already been terminated. " + + "emitCompleted/emitFailed/emitIncomplete may only be called once."); + } + } + + private void completeMulti() { + publisher.close(); + } + + // ══════════════════════════════════════════════════════════ + // Scoped builders + // ══════════════════════════════════════════════════════════ + + /** + * Fluent builder for a generic output item. Returned by the various + * {@code addOutput*()} factory methods. + */ + public static class OutputItemBuilder implements ResponseEventStream.OutputItemBuilder { + protected final ResponseEventStream stream; + protected final long outputIdx; + protected final String itemId; + + OutputItemBuilder(ResponseEventStream stream, long outputIdx, String itemId) { + this.stream = stream; + this.outputIdx = outputIdx; + this.itemId = itemId; + } + + static ResponseOutputItem toOutputItem(Object item) { + if (item instanceof ResponseOutputItem roi) return roi; + if (item instanceof ResponseOutputMessage msg) return ResponseOutputItem.ofMessage(msg); + if (item instanceof ResponseFunctionToolCall ftc) return ResponseOutputItem.ofFunctionCall(ftc); + if (item instanceof ResponseReasoningItem ri) return ResponseOutputItem.ofReasoning(ri); + if (item instanceof ResponseFileSearchToolCall fsc) return ResponseOutputItem.ofFileSearchCall(fsc); + if (item instanceof ResponseFunctionWebSearch ws) return ResponseOutputItem.ofWebSearchCall(ws); + if (item instanceof ResponseCodeInterpreterToolCall ci) return ResponseOutputItem.ofCodeInterpreterCall(ci); + if (item instanceof ResponseOutputItem.ImageGenerationCall ig) + return ResponseOutputItem.ofImageGenerationCall(ig); + if (item instanceof ResponseOutputItem.McpCall mc) return ResponseOutputItem.ofMcpCall(mc); + if (item instanceof ResponseOutputItem.McpListTools mlt) return ResponseOutputItem.ofMcpListTools(mlt); + if (item instanceof ResponseCustomToolCall ct) return ResponseOutputItem.ofCustomToolCall(ct); + throw new IllegalArgumentException("Unsupported output item type: " + item.getClass()); + } + + /** + * Emits a {@code response.output_item.added} event for this item. + */ + public OutputItemBuilder emitAdded(T item) { + ResponseOutputItem outputItem = toOutputItem(item); + stream.addEvent(new ResponseEvent("response.output_item.added", + ResponseStreamEvent.ofOutputItemAdded(new ResponseOutputItemAddedEvent.Builder() + .sequenceNumber(stream.nextSequenceNumber()) + .outputIndex(outputIdx) + .item(outputItem) + .build()))); + return this; + } + + /** + * Emits a {@code response.output_item.done} event for this item + * and tracks it in the parent stream's output list. + */ + public OutputItemBuilder emitDone(T item) { + ResponseOutputItem outputItem = toOutputItem(item); + stream.trackCompletedOutputItem(outputItem, outputIdx); + stream.addEvent(new ResponseEvent("response.output_item.done", + ResponseStreamEvent.ofOutputItemDone(new ResponseOutputItemDoneEvent.Builder() + .sequenceNumber(stream.nextSequenceNumber()) + .outputIndex(outputIdx) + .item(outputItem) + .build()))); + return this; + } + + /** + * Returns the auto-generated item ID for this output item. + */ + public String getItemId() { + return itemId; + } + } + + /** + * Fluent builder for a function call output item. Provides convenience methods + * for the common function call lifecycle: added, argument deltas, argument done, + * and item done — without requiring the caller to manage sequence numbers or + * construct raw event objects. + * + *

Example usage

+ *
{@code
+     * stream.addOutputFunctionCall(func -> func
+     *     .emitAdded("get_weather", "call_123")
+     *     .emitArgumentsDelta("{\"location\":")
+     *     .emitArgumentsDelta("\"Seattle\"}")
+     *     .emitArgumentsDone("get_weather", "{\"location\":\"Seattle\"}")
+     *     .emitDone());
+     * }
+ */ + public static class OutputFunctionCallBuilder implements ResponseEventStream.OutputFunctionCallBuilder { + private final AgentServerResponseEventStream stream; + private final long outputIdx; + private final String itemId; + private final StringBuilder accumulatedArguments = new StringBuilder(); + private String name; + private String callId; + + OutputFunctionCallBuilder(AgentServerResponseEventStream stream, long outputIdx, String itemId) { + this.stream = stream; + this.outputIdx = outputIdx; + this.itemId = itemId; + } + + /** + * Emits a {@code response.output_item.added} event with an in-progress function call. + * + * @param name the function name (e.g. "get_weather") + * @param callId the call ID for this function invocation + */ + public OutputFunctionCallBuilder emitAdded(String name, String callId) { + this.name = name; + this.callId = callId; + ResponseFunctionToolCall call = ResponseFunctionToolCall.builder() + .id(itemId) + .callId(callId) + .name(name) + .arguments("") + .status(ResponseFunctionToolCall.Status.IN_PROGRESS) + .build(); + stream.addEvent(new ResponseEvent("response.output_item.added", + ResponseStreamEvent.ofOutputItemAdded(new ResponseOutputItemAddedEvent.Builder() + .sequenceNumber(stream.nextSequenceNumber()) + .outputIndex(outputIdx) + .item(ResponseOutputItem.ofFunctionCall(call)) + .build()))); + return this; + } + + /** + * Emits a {@code response.function_call_arguments.delta} event and accumulates + * the arguments text. + * + * @param delta the argument text chunk + */ + public OutputFunctionCallBuilder emitArgumentsDelta(String delta) { + accumulatedArguments.append(delta); + stream.addEvent(new ResponseEvent("response.function_call_arguments.delta", + ResponseStreamEvent.ofFunctionCallArgumentsDelta( + ResponseFunctionCallArgumentsDeltaEvent.builder() + .sequenceNumber(stream.nextSequenceNumber()) + .itemId(itemId) + .outputIndex(outputIdx) + .delta(delta) + .build()))); + return this; + } + + /** + * Emits a {@code response.function_call_arguments.done} event with the final + * function name and complete arguments string. + * + * @param name the function name + * @param arguments the complete arguments JSON string + */ + public OutputFunctionCallBuilder emitArgumentsDone(String name, String arguments) { + accumulatedArguments.setLength(0); + accumulatedArguments.append(arguments); + stream.addEvent(new ResponseEvent("response.function_call_arguments.done", + ResponseStreamEvent.ofFunctionCallArgumentsDone( + ResponseFunctionCallArgumentsDoneEvent.builder() + .sequenceNumber(stream.nextSequenceNumber()) + .itemId(itemId) + .outputIndex(outputIdx) + .name(name) + .arguments(arguments) + .build()))); + return this; + } + + /** + * Emits a {@code response.output_item.done} event with the completed function call + * and tracks it in the parent stream's output list. Uses the accumulated arguments + * and the name/callId from {@link #emitAdded(String, String)}. + */ + public OutputFunctionCallBuilder emitDone() { + ResponseFunctionToolCall completedCall = ResponseFunctionToolCall.builder() + .id(itemId) + .callId(callId) + .name(name) + .arguments(accumulatedArguments.toString()) + .status(ResponseFunctionToolCall.Status.COMPLETED) + .build(); + ResponseOutputItem outputItem = ResponseOutputItem.ofFunctionCall(completedCall); + stream.trackCompletedOutputItem(outputItem, outputIdx); + stream.addEvent(new ResponseEvent("response.output_item.done", + ResponseStreamEvent.ofOutputItemDone(new ResponseOutputItemDoneEvent.Builder() + .sequenceNumber(stream.nextSequenceNumber()) + .outputIndex(outputIdx) + .item(outputItem) + .build()))); + return this; + } + + /** + * Returns the auto-generated item ID for this function call. + */ + public String getItemId() { + return itemId; + } + } + + /** + * Fluent builder for a message output item. Provides convenience methods + * for the common message lifecycle and lexically scoped text-part builders. + */ + public static class OutputMessageBuilder implements ResponseEventStream.OutputMessageBuilder { + private final AgentServerResponseEventStream stream; + private final long outputIdx; + private final String itemId; + private final String responseId; + private final List contentParts = new ArrayList<>(); + + OutputMessageBuilder(AgentServerResponseEventStream stream, long outputIdx, String itemId) { + this.stream = stream; + this.outputIdx = outputIdx; + this.itemId = itemId; + this.responseId = stream.responseId; + } + + /** + * Emits a {@code response.output_item.added} event with an in-progress message. + */ + public OutputMessageBuilder emitAdded() { + ResponseOutputItem outputItem = ResponseOutputItem.ofMessage( + buildMessage(ResponseOutputMessage.Status.IN_PROGRESS)); + stream.addEvent(new ResponseEvent("response.output_item.added", + ResponseStreamEvent.ofOutputItemAdded(new ResponseOutputItemAddedEvent.Builder() + .sequenceNumber(stream.nextSequenceNumber()) + .outputIndex(outputIdx) + .item(outputItem) + .build()))); + return this; + } + + /** + * Adds a text content part. The consumer configures delta emissions within + * a lexically scoped block. + *

+ * When the consumer returns, the following events are automatically emitted + * if the consumer did not already emit them: + *

    + *
  • {@code response.output_text.done} — if not already emitted by + * {@link TextPartBuilder#emitDone(String)}
  • + *
  • {@code response.content_part.done} — always emitted
  • + *
+ */ + public OutputMessageBuilder addTextPart(Consumer config) { + long contentIndex = contentParts.size(); + TextPartBuilder textBuilder = new TextPartBuilder(stream, itemId, outputIdx, contentIndex); + config.accept(textBuilder); + + String finalTextValue = textBuilder.accumulatedText.toString(); + + // Auto-emit output_text.done if the consumer didn't call emitDone + if (!textBuilder.doneEmitted) { + textBuilder.emitDone(finalTextValue); + } + + // Always emit content_part.done + ResponseOutputText finalText = ResponseOutputText.builder() + .text(finalTextValue) + .annotations(List.of()) + .logprobs(List.of()) + .build(); + contentParts.add(ResponseOutputMessage.Content.ofOutputText(finalText)); + + stream.addEvent(new ResponseEvent("response.content_part.done", + ResponseStreamEvent.ofContentPartDone(new ResponseContentPartDoneEvent.Builder() + .sequenceNumber(stream.nextSequenceNumber()) + .itemId(itemId) + .outputIndex(outputIdx) + .contentIndex(contentIndex) + .part(finalText) + .build()))); + return this; + } + + /** + * Convenience method that emits a complete text message in one call. + * Equivalent to: + *
{@code
+         * msg.emitAdded()
+         *    .addTextPart(text -> text.emitAdded().emitDelta(content).emitDone(content))
+         *    .emitDone();
+         * }
+ * + * @param content The full text content to emit as a single delta and done event. + * @return this builder for chaining. + */ + public OutputMessageBuilder outputItemMessage(String content) { + return emitAdded() + .addTextPart(text -> text + .emitAdded() + .emitDelta(content) + .emitDone(content)) + .emitDone(); + } + + /** + * Emits a {@code response.output_item.done} event with the completed message + * and tracks it in the parent stream's output list. + */ + public OutputMessageBuilder emitDone() { + ResponseOutputMessage msg = buildMessage(ResponseOutputMessage.Status.COMPLETED); + ResponseOutputItem outputItem = ResponseOutputItem.ofMessage(msg); + stream.trackCompletedOutputItem(outputItem, outputIdx); + stream.addEvent(new ResponseEvent("response.output_item.done", + ResponseStreamEvent.ofOutputItemDone(new ResponseOutputItemDoneEvent.Builder() + .sequenceNumber(stream.nextSequenceNumber()) + .outputIndex(outputIdx) + .item(outputItem) + .build()))); + return this; + } + + /** + * Returns the auto-generated item ID for this message. + */ + public String getItemId() { + return itemId; + } + + private ResponseOutputMessage buildMessage(ResponseOutputMessage.Status status) { + // Build the created_by object matching Foundry platform expectations: + // {"agent": {"type": "agent_id", "name": "", "version": ""}, "response_id": "..."} + Map agentObj = new LinkedHashMap<>(); + agentObj.put("type", "agent_id"); + agentObj.put("name", ""); + agentObj.put("version", ""); + Map createdBy = new LinkedHashMap<>(); + createdBy.put("agent", agentObj); + createdBy.put("response_id", responseId); + + return ResponseOutputMessage.builder() + .id(itemId) + .content(new ArrayList<>(contentParts)) + .status(status) + .putAdditionalProperty("created_by", com.openai.core.JsonValue.from(createdBy)) + .build(); + } + } + + /** + * Fluent builder for a text content part within a message. + * Tracks accumulated delta text for the final {@code content_part.done} event + * (emitted automatically by the parent {@link OutputMessageBuilder}). + */ + public static class TextPartBuilder implements ResponseEventStream.TextPartBuilder { + final StringBuilder accumulatedText = new StringBuilder(); + private final AgentServerResponseEventStream stream; + private final String itemId; + private final long outputIdx; + private final long contentIndex; + boolean doneEmitted; + + TextPartBuilder(AgentServerResponseEventStream stream, String itemId, long outputIdx, long contentIndex) { + this.stream = stream; + this.itemId = itemId; + this.outputIdx = outputIdx; + this.contentIndex = contentIndex; + } + + /** + * Emits a {@code response.content_part.added} event with an empty text part. + */ + public TextPartBuilder emitAdded() { + ResponseOutputText empty = ResponseOutputText.builder() + .text("") + .annotations(List.of()) + .build(); + stream.addEvent(new ResponseEvent("response.content_part.added", + ResponseStreamEvent.ofContentPartAdded(new ResponseContentPartAddedEvent.Builder() + .sequenceNumber(stream.nextSequenceNumber()) + .itemId(itemId) + .outputIndex(outputIdx) + .contentIndex(contentIndex) + .part(empty) + .build()))); + return this; + } + + /** + * Emits a {@code response.output_text.delta} event and accumulates the text. + */ + public TextPartBuilder emitDelta(String delta) { + accumulatedText.append(delta); + stream.addEvent(new ResponseEvent("response.output_text.delta", + ResponseStreamEvent.ofOutputTextDelta(new ResponseTextDeltaEvent.Builder() + .sequenceNumber(stream.nextSequenceNumber()) + .itemId(itemId) + .outputIndex(outputIdx) + .contentIndex(contentIndex) + .delta(delta) + .logprobs(List.of()) + .build()))); + return this; + } + + /** + * Emits a {@code response.output_text.done} event with the final text. + * Also updates the accumulated text to the provided value. + */ + public TextPartBuilder emitDone(String text) { + accumulatedText.setLength(0); + accumulatedText.append(text); + doneEmitted = true; + stream.addEvent(new ResponseEvent("response.output_text.done", + ResponseStreamEvent.ofOutputTextDone(new ResponseTextDoneEvent.Builder() + .sequenceNumber(stream.nextSequenceNumber()) + .itemId(itemId) + .outputIndex(outputIdx) + .contentIndex(contentIndex) + .text(text) + .logprobs(List.of()) + .build()))); + return this; + } + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseItemList.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseItemList.java new file mode 100644 index 000000000000..fea83b682fb9 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseItemList.java @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.openai.models.responses.inputitems.ResponseItemList; + +/** + * Wraps a {@link ResponseItemList} together with an optional {@link AgentReference} + * for the agent server protocol's list-input-items response. + * + * @param agent the agent reference, or {@code null} if not applicable + * @param responseItemList the paginated list of response items + */ +public record AgentServerResponseItemList(AgentReference agent, @JsonUnwrapped ResponseItemList responseItemList) { +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponsesApi.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponsesApi.java new file mode 100644 index 000000000000..cdc9a995be74 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponsesApi.java @@ -0,0 +1,981 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.microsoft.agentserver.api.implementation.FoundryStorageProvider; +import com.microsoft.agentserver.api.implementation.IdGenerator; +import com.microsoft.agentserver.api.implementation.InMemoryResponseProvider; +import com.microsoft.agentserver.api.implementation.ItemConversion; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseItem; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ToolChoiceOptions; +import com.openai.models.responses.inputitems.ResponseItemList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Default implementation of {@link ResponsesApi} for the agent server protocol. + *

+ * Orchestrates request handling by delegating to a {@link ResponseHandler} for response + * generation, and a {@link ResponsesProvider} for state persistence. Supports both + * synchronous and streaming response creation. + *

+ * In hosted environments ({@link FoundryEnvironment#IS_HOSTED}), uses + * {@link FoundryStorageProvider} for persistence; otherwise falls back to + * {@link InMemoryResponseProvider}. + *

+ * Threading model: This class is a synchronous façade over the + * asynchronous {@link ResponsesProvider}. Methods such as {@link #getResponse}, + * {@link #deleteResponse}, and {@link #listInputItems} block the calling thread + * via {@link java.util.concurrent.CompletableFuture#join()} until the provider + * operation completes. Callers should be aware that these methods will block and + * should not be invoked from async pipelines or reactive threads without offloading. + */ +class AgentServerResponsesApi implements ResponsesApi, AutoCloseable { + + private static final Logger LOGGER = LoggerFactory.getLogger(AgentServerResponsesApi.class); + + private final ResponseHandler responseHandler; + private final ResponsesProvider provider; + + /** + * Executor for background ({@code background=true}) response processing. Daemon + * threads so the JVM is not kept alive by in-flight background work. + */ + private final ExecutorService backgroundExecutor = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "agentserver-background"); + t.setDaemon(true); + return t; + }); + + /** + * Process-scoped registry of in-flight background executions. + * Used to coordinate cancellation with the + * background finalisation so cancellation always wins. + */ + private final ExecutionTracker executionTracker = new ExecutionTracker(); + + /** + * In-memory buffer of SSE events for replay via {@code GET /responses/{id}?stream=true}. + * + */ + private final EventReplayStore eventReplayStore = new EventReplayStore(); + + public AgentServerResponsesApi(ResponseHandler responseHandler, ResponsesProvider provider) { + this.responseHandler = responseHandler; + this.provider = provider; + } + + /** + * Tracks active background executions by response ID so that cancel/finalise + * can coordinate. + */ + static final class ExecutionTracker { + private final Map executions = new ConcurrentHashMap<>(); + + Execution register(String responseId, boolean background, boolean store) { + Execution execution = new Execution(background, store); + executions.put(responseId, execution); + return execution; + } + + Execution get(String responseId) { + return executions.get(responseId); + } + + void evict(String responseId) { + executions.remove(responseId); + } + } + + /** + * Coordination state for a single in-flight execution. The mutable flags + * ({@code cancelRequested}, {@code finalized}) are guarded by the monitor of + * the {@code Execution} instance itself. + */ + static final class Execution { + final boolean background; + final boolean store; + boolean cancelRequested; + boolean finalized; + + Execution(boolean background, boolean store) { + this.background = background; + this.store = store; + } + } + + private static String extractResponseIdFromMetadata(AgentServerCreateResponse createResponse) { + try { + return createResponse.responseCreateParams().metadata() + .map(meta -> { + com.openai.core.JsonValue val = meta._additionalProperties().get("response_id"); + if (val == null) return null; + @SuppressWarnings("unchecked") + java.util.Optional str = val.asString(); + return str.orElse(null); + }) + .orElse(null); + } catch (Exception e) { + LOGGER.debug("Could not extract response_id from metadata", e); + return null; + } + } + + private static String extractResponseItemId(ResponseItem item) { + return ItemConversion.extractItemId(item); + } + + /** + * Resolves the response ID for a new create request, in priority order: + *

    + *
  1. {@code x-agent-response-id} HTTP header.
  2. + *
  3. {@code metadata.response_id} field on the request body (legacy override).
  4. + *
  5. Freshly generated via {@link IdGenerator#generateResponseId()}.
  6. + *
+ */ + private static String resolveResponseId(AgentServerCreateResponse createResponse, RequestMetadata metadata) { + if (metadata != null) { + String override = metadata.getResponseIdOverride(); + if (override != null && !override.isEmpty()) { + return override; + } + } + String fromBody = extractResponseIdFromMetadata(createResponse); + if (fromBody != null) { + return fromBody; + } + return new IdGenerator(null).generateResponseId(); + } + + /** + * Auto-stamps the resolved {@code agent_session_id} onto a + * {@link Response} by adding it to the additional-properties map. Returns the + * original instance unchanged when {@code sessionId} is {@code null}/empty. + */ + private static Response stampSessionId(Response resp, String sessionId) { + if (resp == null || sessionId == null || sessionId.isEmpty()) { + return resp; + } + return resp.toBuilder() + .putAdditionalProperty("agent_session_id", com.openai.core.JsonValue.from(sessionId)) + .build(); + } + + /** + * Combines (response-ID resolution) and (session-ID stamping) and + * normalizes all child IDs (output message ids, conversation id) to share + * the resolved response's partition key. The Foundry storage backend routes + * an envelope to a single shard by partition key, so a mix of partitions in + * a single envelope causes the storage create to fail with HTTP 500. + */ + private static Response normalizeIdsAndStamp(Response handlerResp, String responseId, String sessionId, AgentReference agentRef) { + Response.Builder rebuilt = handlerResp.toBuilder(); + if (!responseId.equals(handlerResp.id())) { + rebuilt.id(responseId); + } + + // Re-partition any output message item IDs whose partition doesn't already + // match the resolved response. The Foundry storage backend routes envelopes + // by partition key; mixed partitions in a single envelope are rejected. + // (Streaming already produces aligned IDs; this primarily fixes the sync + // path where ResponseBuilder generates a fresh partition.) + String partitionKey; + try { + partitionKey = IdGenerator.extractPartitionKey(responseId); + } catch (RuntimeException e) { + partitionKey = null; + } + + if (partitionKey != null) { + IdGenerator idGen = new IdGenerator(partitionKey); + List normalizedOutput = new ArrayList<>(); + for (ResponseOutputItem item : handlerResp.output()) { + if (item.isMessage()) { + ResponseOutputMessage msg = item.asMessage(); + if (!sharesPartition(msg.id(), partitionKey)) { + normalizedOutput.add(ResponseOutputItem.ofMessage( + msg.toBuilder().id(idGen.generateMessageItemId()).build())); + continue; + } + } + normalizedOutput.add(item); + } + rebuilt.output(normalizedOutput); + } + + if (sessionId != null && !sessionId.isEmpty()) { + rebuilt.putAdditionalProperty( + "agent_session_id", com.openai.core.JsonValue.from(sessionId)); + } + + // echo the request's agent_reference onto the response so the + // platform storage backend can correlate the persisted response with the + // originating agent. Stored as an additional property because the openai + // Response model has no first-class agent_reference field. + if (agentRef != null) { + com.fasterxml.jackson.databind.node.ObjectNode refNode = com.microsoft.agentserver.api.serialization.ObjectMapperFactory + .getObjectMapper().createObjectNode(); + if (agentRef.type() != null) { + refNode.put("type", agentRef.type().toString().toLowerCase(java.util.Locale.ROOT)); + } + if (agentRef.name() != null) { + refNode.put("name", agentRef.name()); + } + if (agentRef.version() != null) { + refNode.put("version", agentRef.version()); + } + if (agentRef.label() != null) { + refNode.put("label", agentRef.label()); + } + rebuilt.putAdditionalProperty("agent_reference", com.openai.core.JsonValue.from(refNode)); + } + + return rebuilt.build(); + } + + private static boolean sharesPartition(String id, String partitionKey) { + if (id == null) { + return false; + } + try { + return partitionKey.equals(IdGenerator.extractPartitionKey(id)); + } catch (RuntimeException e) { + return false; + } + } + + /** + * Validates a response-ID path parameter before any lookup. Malformed + * IDs produce HTTP 400 ({@code "Malformed identifier."}) rather than 404. + * + * @param responseId the path parameter to validate + * @throws ApiException 400 if the ID is not a well-formed response ID + */ + private static void validateResponseId(String responseId) throws ApiException { + if (!IdGenerator.isValidResponseId(responseId)) { + // param="responseId{}" so clients can echo the offending value. + String param = "responseId{" + (responseId == null ? "" : responseId) + "}"; + throw new ApiException(400, + ApiError.invalidRequest("Malformed identifier.", ApiError.CODE_INVALID_PARAMETERS, param)); + } + } + + @Override + public CreateResponse createResponse(AgentServerCreateResponse createResponse) throws ApiException { + return createResponse(createResponse, RequestMetadata.EMPTY); + } + + @Override + public CreateResponse createResponse(AgentServerCreateResponse createResponse, RequestMetadata metadata) throws ApiException { + LOGGER.debug("createResponse called"); + + ResponseCreateParams.Body params = createResponse.responseCreateParams(); + boolean background = params.background().orElse(false); + boolean store = params.store().orElse(true); + + // background=true requires store=true. + if (background && !store) { + throw new ApiException(400, ApiError.invalidRequest("The 'background' parameter requires 'store' to be true.", ApiError.CODE_UNSUPPORTED_PARAMETER, "background")); + } + + String responseId = resolveResponseId(createResponse, metadata); + // resolve the session ID once for this request; stamped on every + // Response object that leaves the API. + String sessionId = SessionIdResolver.resolve(createResponse, FoundryEnvironment.SESSION_ID); + + ResponseContext context = buildContext(responseId, sessionId, createResponse, metadata); + + // : emit the spec-required invoke_agent {model} span around handler + // execution. The span scopes everything the handler does so per-tool / + // per-LLM child spans (when emitted) attach to it. + io.opentelemetry.api.trace.Span span = Observability.startInvokeAgentSpan( + extractModelName(params), + responseId, + extractConversationId(params), + createResponse.agent() != null ? createResponse.agent().name() : null, + createResponse.agent() != null ? createResponse.agent().version() : null, + false); + try (io.opentelemetry.context.Scope ignored = span.makeCurrent()) { + if (background) { + CreateResponse bg = createBackgroundResponse(responseId, sessionId, context, createResponse); + return bg; + } + + CreateResponse response; + try { + response = responseHandler.createResponse(context, createResponse); + } catch (RuntimeException e) { + String code = e.getClass().getSimpleName(); + String msg = e.getMessage(); + if (e.getCause() instanceof ApiException ae) { + code = ae.getError() != null ? ae.getError().code() : code; + msg = ae.getMessage(); + } + Observability.recordInvokeAgentError(span, code, msg, e); + throw e; + } + + // ensure the resolved response ID (which may have come from the + // x-agent-response-id header or `metadata.response_id`) is the one that + // both the client sees and storage uses — even when handlers build their + // own ID internally via ResponseBuilder. Also stamp the session ID, + // and re-partition child IDs (msg_, conv_) to match the resolved response's + // partition key (required by Foundry storage so the envelope routes to a + // single shard). + Response handlerResp = response.response(); + if (handlerResp != null) { + handlerResp = normalizeIdsAndStamp(handlerResp, responseId, sessionId, createResponse.agent()); + response = new CreateResponse(response.agent(), handlerResp); + } + + persistResponse(context, response); + + return response; + } finally { + span.end(); + } + } + + /** + * Handles {@code background=true}, non-streaming creation (matrix C3 / Rules + * ). Persists an initial {@code in_progress} snapshot so the + * response is immediately retrievable via GET, returns that snapshot to the + * caller right away, and processes the handler asynchronously, persisting the + * terminal result when done. Cancellation always wins. + */ + private CreateResponse createBackgroundResponse( + String responseId, String sessionId, ResponseContext context, AgentServerCreateResponse createResponse) { + + // Phase 1: persist the in_progress snapshot (with input items) so GET works + // while processing runs in the background. stamp the resolved session ID. + Response initial = stampSessionId( + buildInitialResponse(responseId, createResponse, ResponseStatus.IN_PROGRESS), + sessionId); + persistStreamingResponse(context, initial); + + Execution execution = executionTracker.register(responseId, true, true); + final String finalResponseId = responseId; + final String finalSessionId = sessionId; + + backgroundExecutor.submit(() -> { + try { + CreateResponse result = responseHandler.createResponse(context, createResponse); + // Re-stamp the terminal response with the response ID we already handed + // to the client (Phase 1), the background flag, and the session ID, so + // GET on that ID observes a coherent completed background response. + Response terminal = result.response(); + if (terminal != null) { + Response.Builder rebuilt = terminal.toBuilder().background(true); + if (!finalResponseId.equals(terminal.id())) { + rebuilt.id(finalResponseId); + } + rebuilt.putAdditionalProperty( + "agent_session_id", com.openai.core.JsonValue.from(finalSessionId)); + terminal = rebuilt.build(); + } + final Response finalTerminal = terminal; + synchronized (execution) { + if (!execution.cancelRequested && !execution.finalized && finalTerminal != null) { + persistStreamingResponse(context, finalTerminal); + execution.finalized = true; + } + } + } catch (Exception e) { + LOGGER.error("Background response {} processing failed", finalResponseId, e); + synchronized (execution) { + if (!execution.cancelRequested && !execution.finalized) { + Response failed = stampSessionId( + buildInitialResponse(finalResponseId, createResponse, ResponseStatus.FAILED), + finalSessionId); + persistStreamingResponse(context, failed); + execution.finalized = true; + } + } + } finally { + executionTracker.evict(finalResponseId); + } + }); + + // return immediately with the in_progress snapshot. + return new CreateResponse(null, initial); + } + + /** + * Builds a minimal {@link Response} snapshot for a background response in the + * given lifecycle {@code status} (used for the initial {@code in_progress} + * persistence and for failure fallback). + */ + private Response buildInitialResponse( + String responseId, AgentServerCreateResponse createResponse, ResponseStatus status) { + + ResponseCreateParams.Body params = createResponse.responseCreateParams(); + + String conversationId = params.conversation() + .flatMap(conv -> { + if (conv.isId()) { + return Optional.of(conv.asId()); + } + if (conv.isResponseConversationParam()) { + return Optional.of(conv.asResponseConversationParam().id()); + } + return Optional.empty(); + }) + .orElse(null); + + Response.Builder builder = Response.builder() + .id(responseId) + .createdAt(System.currentTimeMillis() / 1000.0) + .status(status) + .background(true) + .output(List.of()) + .error(Optional.empty()) + .incompleteDetails(Optional.empty()) + .instructions(Optional.empty()) + .metadata(Optional.empty()) + .parallelToolCalls(false) + .temperature(Optional.empty()) + .toolChoice(ToolChoiceOptions.AUTO) + .tools(List.of()) + .topP(Optional.empty()) + .previousResponseId(params.previousResponseId().orElse(null)); + + if (params.model().isPresent()) { + params.model().ifPresent(builder::model); + } else { + builder.model(FoundryEnvironment.AGENT_NAME != null ? FoundryEnvironment.AGENT_NAME : "unknown-agent"); + } + + if (conversationId != null) { + builder.conversation(Response.Conversation.builder().id(conversationId).build()); + } + + return builder.build(); + } + + @Override + public ResponseEventStream createStreamingResponse(AgentServerCreateResponse createResponse) throws ApiException { + return createStreamingResponse(createResponse, RequestMetadata.EMPTY); + } + + @Override + public ResponseEventStream createStreamingResponse(AgentServerCreateResponse createResponse, RequestMetadata metadata) throws ApiException { + LOGGER.debug("createStreamingResponse called"); + + ResponseCreateParams.Body params = createResponse.responseCreateParams(); + boolean background = params.background().orElse(false); + boolean store = params.store().orElse(true); + + // background=true requires store=true (matrix C8). + if (background && !store) { + throw new ApiException(400, ApiError.invalidRequest("The 'background' parameter requires 'store' to be true.", ApiError.CODE_UNSUPPORTED_PARAMETER, "background")); + } + + String responseId = resolveResponseId(createResponse, metadata); + // resolve the session ID once for this request. + String sessionId = SessionIdResolver.resolve(createResponse, FoundryEnvironment.SESSION_ID); + + ResponseContext context = buildContext(responseId, sessionId, createResponse, metadata); + + // : emit the spec-required invoke_agent {model} span. For streaming the + // span lives until the stream terminates (completion or failure), so the + // handler's tool/LLM child spans (when emitted) attach to it. + io.opentelemetry.api.trace.Span invokeSpan = Observability.startInvokeAgentSpan( + extractModelName(params), + responseId, + extractConversationId(params), + createResponse.agent() != null ? createResponse.agent().name() : null, + createResponse.agent() != null ? createResponse.agent().version() : null, + true); + ResponseEventStream stream; + try (io.opentelemetry.context.Scope ignored = invokeSpan.makeCurrent()) { + stream = responseHandler.createAsync(context, createResponse); + } catch (RuntimeException e) { + Observability.recordInvokeAgentError(invokeSpan, + e.getClass().getSimpleName(), e.getMessage(), e); + invokeSpan.end(); + throw e; + } + + // stamp the resolved session ID on every snapshot the stream produces. + if (stream instanceof AgentServerResponseEventStream impl) { + impl.setAgentSessionId(sessionId); + } + + // C4 (background+stream): persist an initial in_progress snapshot so GET + // works immediately while the response streams in the background. + if (background) { + Response initial = stampSessionId( + buildInitialResponse(responseId, createResponse, ResponseStatus.IN_PROGRESS), + sessionId); + persistStreamingResponse(context, initial); + } + + // For stored responses, init the replay buffer so a mid-stream replay + // request sees `hasBuffer()==true` (avoiding a false "not stream=true" + // rejection) and gets whatever has been emitted so far. + if (store) { + eventReplayStore.initBuffer(responseId); + // Capture any events the handler emitted synchronously before we wired + // up the subscriber below. + for (ResponseEvent existing : stream.getEvents()) { + eventReplayStore.append(responseId, existing); + } + } + + // Register the in-flight execution so cancel/disconnect can coordinate with + // the terminal persistence. Background streams are tagged as background so + // signalClientDisconnected is a no-op for them; explicit cancel + // remains supported. Non-background streams enable. + final Execution execution = executionTracker.register(responseId, background, store); + + final String finalResponseId = responseId; + final boolean storeEnabled = store; + final boolean isBackground = background; + // Track the count of events we already appended above so we don't + // double-buffer those when the subscribe callback fires. + final int alreadyBuffered = store ? stream.getEvents().size() : 0; + final java.util.concurrent.atomic.AtomicInteger seenCount = + new java.util.concurrent.atomic.AtomicInteger(0); + stream.subscribe( + event -> { + // Live mid-stream buffering: append each new event + // so a concurrent replay request observes it. Skip the prefix the + // synchronous handler already emitted before subscribe attached. + if (storeEnabled && seenCount.getAndIncrement() >= alreadyBuffered) { + eventReplayStore.append(finalResponseId, event); + } + }, + failure -> { + LOGGER.error("Stream failed for response {}", finalResponseId, failure); + Observability.recordInvokeAgentError(invokeSpan, + failure.getClass().getSimpleName(), failure.getMessage(), failure); + invokeSpan.end(); + }, + () -> { + LOGGER.debug("Stream completed for response {}", finalResponseId); + try { + synchronized (execution) { + if (execution.cancelRequested || execution.finalized) { + // Cancellation won the race; do not overwrite + // the cancelled snapshot with the natural terminal state. + return; + } + Response resp = stream.getResponse(); + if (resp != null) { + if (isBackground) { + // C4: re-stamp background=true so GET on the terminal + // observes a coherent background response (mirroring C3). + resp = resp.toBuilder().background(true).build(); + } + // Match the sync path: normalize child IDs to the resolved + // response's partition, stamp agent_reference and + // agent_session_id. Without these the storage POST + // fails (envelope partition mismatch, missing agent_reference). + resp = normalizeIdsAndStamp(resp, finalResponseId, sessionId, createResponse.agent()); + persistStreamingResponse(context, resp); + } + execution.finalized = true; + } + } catch (Exception e) { + LOGGER.error("Failed to persist streaming response", e); + } finally { + executionTracker.evict(finalResponseId); + invokeSpan.end(); + } + } + ); + + return stream; + } + + private static String extractModelName(ResponseCreateParams.Body params) { + try { + if (params.model().isPresent()) { + var model = params.model().get(); + if (model._json().isPresent() && model._json().get().asString().isPresent()) { + return model._json().get().asString().get().toString(); + } + } + } catch (Exception ignored) { + // fall through + } + return null; + } + + private static String extractConversationId(ResponseCreateParams.Body params) { + try { + return params.conversation() + .flatMap(c -> c.isId() ? java.util.Optional.of(c.asId()) : java.util.Optional.empty()) + .orElse(null); + } catch (Exception ignored) { + return null; + } + } + + @Override + public com.openai.models.responses.Response getResponse(String responseId, List include) throws ApiException { + return getResponse(responseId, include, RequestMetadata.EMPTY); + } + + @Override + public com.openai.models.responses.Response getResponse(String responseId, List include, RequestMetadata metadata) throws ApiException { + LOGGER.debug("getResponse called for responseId={}", responseId); + validateResponseId(responseId); + com.openai.models.responses.Response resp = provider.getResponseAsync(responseId, metadata.getIsolation()) + .join() + .orElse(null); + + if (resp == null) { + throw new ApiException(404, ApiError.invalidRequest(responseId + " not found")); + } + + return resp; + } + + @Override + public ResponseStreamReplay replayResponseStream(String responseId, Integer startingAfter) throws ApiException { + LOGGER.debug("replayResponseStream called for responseId={}, startingAfter={}", responseId, startingAfter); + validateResponseId(responseId); + + // Not found / store=false → 404. + com.openai.models.responses.Response resp = provider.getResponseAsync(responseId) + .join() + .orElse(null); + if (resp == null) { + throw new ApiException(404, ApiError.invalidRequest(responseId + " not found")); + } + + // SSE replay requires background=true (checked before stream). + if (!resp.background().orElse(false)) { + throw new ApiException(400, ApiError.invalidRequest( + "This response cannot be streamed because it was not created with background=true.", + ApiError.CODE_INVALID_REQUEST, "stream")); + } + + // SSE replay requires stream=true at creation. The presence of a replay + // buffer indicates the response was created streaming. + if (!eventReplayStore.hasBuffer(responseId)) { + throw new ApiException(400, ApiError.invalidRequest( + "This response cannot be streamed because it was not created with stream=true.", + ApiError.CODE_INVALID_REQUEST, "stream")); + } + + List events = eventReplayStore.replay(responseId, startingAfter) + .orElseGet(List::of); + return new ResponseStreamReplay(events); + } + + @Override + public com.openai.models.responses.Response cancelResponse(String responseId) throws ApiException { + LOGGER.debug("cancelResponse called for responseId={}", responseId); + validateResponseId(responseId); + + com.openai.models.responses.Response resp = provider.getResponseAsync(responseId) + .join() + .orElse(null); + + // (not found) → 404. Non-background in-flight responses are also not findable. + if (resp == null) { + throw new ApiException(404, ApiError.invalidRequest(responseId + " not found")); + } + + // the background check happens first, regardless of status. + boolean background = resp.background().orElse(false); + if (!background) { + throw new ApiException(400, ApiError.invalidRequest("Cannot cancel a synchronous response.")); + } + + com.openai.models.responses.ResponseStatus status = resp.status().orElse(null); + + // cancelling an already-cancelled response is idempotent. + if (com.openai.models.responses.ResponseStatus.CANCELLED.equals(status)) { + return resp; + } + + // terminal states cannot be cancelled. + if (com.openai.models.responses.ResponseStatus.COMPLETED.equals(status)) { + throw new ApiException(400, ApiError.invalidRequest("Cannot cancel a completed response.")); + } + if (com.openai.models.responses.ResponseStatus.FAILED.equals(status)) { + throw new ApiException(400, ApiError.invalidRequest("Cannot cancel a failed response.")); + } + if (com.openai.models.responses.ResponseStatus.INCOMPLETE.equals(status)) { + throw new ApiException(400, ApiError.invalidRequest("Cannot cancel a response in terminal state.")); + } + + // queued or in_progress → wind down to cancelled with output cleared. + com.openai.models.responses.Response cancelled = resp.toBuilder() + .status(com.openai.models.responses.ResponseStatus.CANCELLED) + .output(Collections.emptyList()) + .build(); + + // Coordinate with any in-flight background execution so the background + // finaliser does not overwrite the cancelled state. + Execution execution = executionTracker.get(responseId); + if (execution != null) { + synchronized (execution) { + execution.cancelRequested = true; + provider.saveResponseAsync(responseId, cancelled, null, null).join(); + } + } else { + provider.saveResponseAsync(responseId, cancelled, null, null).join(); + } + LOGGER.debug("Response {} cancelled (output cleared)", responseId); + return cancelled; + } + + @Override + public void signalClientDisconnected(String responseId) { + // only non-background responses are affected by client disconnect. + // Background responses outlive the connection — no-op there. + Execution execution = executionTracker.get(responseId); + if (execution == null || execution.background) { + return; + } + synchronized (execution) { + if (execution.cancelRequested || execution.finalized) { + return; + } + execution.cancelRequested = true; + // Persist the cancelled snapshot only when store=true; per, store=false + // disconnects produce no retrievable response (GET → 404). + if (execution.store) { + try { + com.openai.models.responses.Response existing = + provider.getResponseAsync(responseId).join().orElse(null); + com.openai.models.responses.Response.Builder builder = existing != null + ? existing.toBuilder() + : com.openai.models.responses.Response.builder() + .id(responseId) + .createdAt(System.currentTimeMillis() / 1000.0) + .model("") + .parallelToolCalls(false) + .tools(java.util.List.of()) + .error(java.util.Optional.empty()) + .incompleteDetails(java.util.Optional.empty()) + .instructions(java.util.Optional.empty()) + .metadata(java.util.Optional.empty()) + .temperature(java.util.Optional.empty()) + .topP(java.util.Optional.empty()) + .toolChoice(com.openai.models.responses.ToolChoiceOptions.AUTO); + com.openai.models.responses.Response cancelled = builder + .status(com.openai.models.responses.ResponseStatus.CANCELLED) + .output(java.util.Collections.emptyList()) + .build(); + provider.saveResponseAsync(responseId, cancelled, null, null).join(); + } catch (Exception e) { + LOGGER.warn("Failed to persist cancelled-on-disconnect snapshot for {}", responseId, e); + } + } + LOGGER.debug("Response {} cancelled on client disconnect", responseId); + } + } + + // ── Private helpers ───────────────────────────────────────── + + private ResponseContext buildContext(String responseId, String sessionId, AgentServerCreateResponse createResponse, RequestMetadata metadata) { + return ResponseContext.builder() + .responseId(responseId) + .provider(provider) + .request(createResponse.responseCreateParams()) + .isolation(metadata.getIsolation()) + .clientHeaders(metadata.getClientHeaders()) + .queryParameters(metadata.getQueryParameters()) + .requestId(metadata.getRequestId()) + .sessionId(sessionId) + .build(); + } + + @Override + public void deleteResponse(String responseId) throws ApiException { + LOGGER.debug("deleteResponse called for responseId={}", responseId); + validateResponseId(responseId); + provider.deleteResponseAsync(responseId).join(); + // Best-effort removal of any buffered SSE events so replay is no longer possible. + eventReplayStore.delete(responseId); + } + + @Override + public AgentServerResponseItemList listInputItems(String responseId, Integer limit, String order, String after, String before, List include) throws ApiException { + LOGGER.debug("listInputItems called for responseId={}", responseId); + validateResponseId(responseId); + + // Verify the response exists + com.openai.models.responses.Response resp = provider.getResponseAsync(responseId) + .join() + .orElse(null); + + if (resp == null) { + throw new ApiException(404, ApiError.invalidRequest(responseId + " not found")); + } + + // Retrieve the stored input items (not output items) + List allItems = provider.getInputItemsForResponseAsync(responseId) + .join(); + + int effectiveLimit = (limit != null) ? limit : 20; + boolean descending = "desc".equalsIgnoreCase(order); + + + // Apply cursor-based pagination. `after` slices off everything up to and including + // that item; `before` slices off everything from that item onward; both combine + // as an exclusive range. Items always operate on the underlying stored order + // (i.e. cursor semantics are positional and order-independent, per ). + int fromIdx = 0; + int toIdx = allItems.size(); + if (after != null && !after.isEmpty()) { + for (int i = 0; i < allItems.size(); i++) { + if (after.equals(extractResponseItemId(allItems.get(i)))) { + fromIdx = Math.max(fromIdx, i + 1); + break; + } + } + } + if (before != null && !before.isEmpty()) { + for (int i = 0; i < allItems.size(); i++) { + if (before.equals(extractResponseItemId(allItems.get(i)))) { + toIdx = Math.min(toIdx, i); + break; + } + } + } + List windowed = fromIdx < toIdx + ? allItems.subList(fromIdx, toIdx) + : List.of(); + + // Apply ordering + List ordered; + if (descending) { + ordered = new ArrayList<>(windowed); + Collections.reverse(ordered); + } else { + ordered = windowed; + } + + // Apply limit + List page = ordered.size() > effectiveLimit + ? ordered.subList(0, effectiveLimit) + : ordered; + + boolean hasMore = ordered.size() > effectiveLimit; + String firstId = page.isEmpty() ? null : extractResponseItemId(page.get(0)); + String lastId = page.isEmpty() ? null : extractResponseItemId(page.get(page.size() - 1)); + + ResponseItemList itemList = ResponseItemList.builder() + .data(page) + .firstId(firstId != null ? firstId : "") + .lastId(lastId != null ? lastId : "") + .hasMore(hasMore) + .build(); + + return new AgentServerResponseItemList(null, itemList); + } + + private void persistResponse(ResponseContext context, CreateResponse response) { + if (response.response() != null) { + com.openai.models.responses.Response resp = response.response(); + persistStreamingResponse(context, resp); + } + } + + private void persistStreamingResponse(ResponseContext context, com.openai.models.responses.Response resp) { + String previousResponseId = resp.previousResponseId().orElse(null); + String conversationId = resp.conversation() + .map(com.openai.models.responses.Response.Conversation::id) + .orElse(null); + + // Build role-preserving input_items directly from the raw request body. + // The context.getInputItemsAsync() path returns ResponseOutputItems which + // are hardcoded to role=assistant — wrong for stored conversation history. + List inputItems = Collections.emptyList(); + try { + ResponseCreateParams.Body body = context instanceof AgentServerResponseContext c + ? c.getRequestBody() : null; + if (body != null && body.input().isPresent()) { + ResponseCreateParams.Input in = body.input().get(); + if (in.isResponse()) { + IdGenerator idGen = new IdGenerator(IdGenerator.extractPartitionKey(resp.id())); + List items = new ArrayList<>(); + for (com.openai.models.responses.ResponseInputItem raw : in.asResponse()) { + ResponseItem converted = ItemConversion.toResponseItem(raw, idGen); + if (converted != null) { + items.add(converted); + } + } + inputItems = items; + } else if (in.isText() && !in.asText().isEmpty()) { + // Plain-text input — synthesize a single user message. + IdGenerator idGen = new IdGenerator(IdGenerator.extractPartitionKey(resp.id())); + inputItems = List.of(ItemConversion.toResponseItem( + com.openai.models.responses.ResponseInputItem.ofEasyInputMessage( + com.openai.models.responses.EasyInputMessage.builder() + .role(com.openai.models.responses.EasyInputMessage.Role.USER) + .content(com.openai.models.responses.EasyInputMessage.Content.ofTextInput(in.asText())) + .build()), + idGen)); + } + } + } catch (Exception e) { + LOGGER.warn("Failed to gather input items for response {}", resp.id(), e); + } + + // Get history item IDs if there's a previous response + List historyItemIds = Collections.emptyList(); + if (previousResponseId != null) { + try { + historyItemIds = provider.getHistoryItemIdsAsync(previousResponseId, conversationId, 100).join(); + } catch (Exception e) { + LOGGER.warn("Failed to get history item IDs for response {}", resp.id(), e); + } + } + + // Single envelope call: POST /responses with {response, input_items, history_item_ids} + try { + // Pass isolation context so storage API receives platform isolation headers + IsolationContext isolation = context.getIsolation(); + if (provider instanceof FoundryStorageProvider foundryProvider) { + foundryProvider.createResponseAsync(resp.id(), resp, inputItems, historyItemIds, isolation).join(); + } else { + provider.createResponseAsync(resp.id(), resp, inputItems, historyItemIds).join(); + } + } catch (Exception e) { + LOGGER.error("Failed to persist response {} to Foundry storage", resp.id(), e); + } + } + + /** + * Shuts down the background executor, waiting up to 5 seconds for in-flight + * tasks to complete. Should be called when the hosting container is stopping + * (e.g. via a JVM shutdown hook or framework lifecycle callback). + */ + @Override + public void close() { + backgroundExecutor.shutdown(); + try { + if (!backgroundExecutor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) { + LOGGER.warn("Background executor did not terminate within 5s; forcing shutdown"); + backgroundExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + LOGGER.warn("Interrupted while awaiting background executor shutdown"); + backgroundExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerVersion.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerVersion.java new file mode 100644 index 000000000000..180f954c9752 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerVersion.java @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +/** + * Provides the {@code x-platform-server} header value identifying this library. + *

+ * The header value is computed once at class initialization from the library version + * and the current JVM version, e.g.: + * {@code azure-ai-agentserver-java/1.0.0 (java/21)} + */ +public final class AgentServerVersion { + + private static final AgentServerVersion INSTANCE = new AgentServerVersion(); + + private final String composedHeader; + + private AgentServerVersion() { + String javaVersion = System.getProperty("java.version", "unknown"); + composedHeader = "azure-ai-agentserver-java/" + getLibraryVersion() + " (java/" + javaVersion + ")"; + } + + /** + * Returns the singleton instance. + */ + public static AgentServerVersion getInstance() { + return INSTANCE; + } + + /** + * Returns the {@code x-platform-server} header value for this library. + */ + public String getHeaderValue() { + return composedHeader; + } + + private static String getLibraryVersion() { + Package pkg = AgentServerVersion.class.getPackage(); + if (pkg != null && pkg.getImplementationVersion() != null) { + return pkg.getImplementationVersion(); + } + return "1.0.0-SNAPSHOT"; + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ApiError.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ApiError.java new file mode 100644 index 000000000000..91cebc0e7ac6 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ApiError.java @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Map; + +/** + * Structured HTTP error body as defined by the API spec. + * Serialised inside an outer envelope: {@code { "error": ApiError }}. + *

+ * Required fields: {@link #message}, {@link #type}, {@link #code} (which may be + * {@code null} but is always present). Optional fields ({@link #param}, + * {@link #details}, {@link #additionalInfo}) are omitted when {@code null}. + * + * @param message human-readable description (always present) + * @param type error category, e.g. {@code "invalid_request_error"}, {@code "server_error"} (always present) + * @param code sub-code such as {@code "invalid_parameters"}, {@code "unsupported_parameter"}; + * always serialised, may be {@code null} + * @param param the offending request parameter, JSON-path or path-param name; optional + * @param details nested error objects for multi-error validation; optional + * @param additionalInfo arbitrary supplemental key/value context; optional + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ApiError( + @JsonProperty("message") String message, + @JsonProperty("type") String type, + // `code` is always serialised (may be null) per the envelope spec. + @JsonInclude(JsonInclude.Include.ALWAYS) @JsonProperty("code") String code, + @JsonProperty("param") String param, + @JsonProperty("details") List details, + @JsonProperty("additionalInfo") Map additionalInfo) { + + /** + * Standard error type for client-side / 4xx errors. + */ + public static final String TYPE_INVALID_REQUEST = "invalid_request_error"; + /** + * Standard error type for unhandled server errors (500). + */ + public static final String TYPE_SERVER_ERROR = "server_error"; + + /** + * Generic 4xx sub-code used when no more specific code applies. + */ + public static final String CODE_INVALID_REQUEST = "invalid_request_error"; + /** + * Malformed path identifier. + */ + public static final String CODE_INVALID_PARAMETERS = "invalid_parameters"; + /** + * Parameter present but unsupported in this combination. + */ + public static final String CODE_UNSUPPORTED_PARAMETER = "unsupported_parameter"; + /** + * Required parameter missing from the request. + */ + public static final String CODE_MISSING_REQUIRED_PARAMETER = "missing_required_parameter"; + /** + * Unknown / unrecognised parameter on the request. + */ + public static final String CODE_UNKNOWN_PARAMETER = "unknown_parameter"; + /** + * A generic field-level validation failure inside a {@code details[]} entry. + */ + public static final String CODE_INVALID_VALUE = "invalid_value"; + /** + * Unhandled server-side failure. + */ + public static final String CODE_SERVER_ERROR = "server_error"; + + /** + * Convenience for the common 4xx case with no offending parameter. + */ + public static ApiError invalidRequest(String message) { + return new ApiError(message, TYPE_INVALID_REQUEST, CODE_INVALID_REQUEST, null, null, null); + } + + /** + * Convenience for a 4xx case with a specific sub-code and offending parameter. + */ + public static ApiError invalidRequest(String message, String code, String param) { + return new ApiError(message, TYPE_INVALID_REQUEST, code, param, null, null); + } + + /** + * Convenience for a 500. + */ + public static ApiError serverError(String message) { + return new ApiError(message, TYPE_SERVER_ERROR, CODE_SERVER_ERROR, null, null, null); + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ApiException.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ApiException.java new file mode 100644 index 000000000000..58dc85a29e6f --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ApiException.java @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import java.util.Objects; + +/** + * Exception thrown when an API operation fails. + *

+ * Carries an HTTP status code and a structured {@link ApiError} body that the + * hosting framework adapter serialises as the standard error envelope + * ({@code { "error": { "message", "type", "code", "param"?, "details"?, "additionalInfo"? } }}) + * per the API spec. + */ +public class ApiException extends Exception { + + private final int statusCode; + private final ApiError error; + + /** + * Creates an {@code ApiException} with the given status code and structured error body. + * + * @param statusCode the HTTP status code representing the error + * @param error the structured error body (required) + */ + public ApiException(int statusCode, ApiError error) { + super("API error: HTTP " + statusCode + " — " + Objects.requireNonNull(error, "error").message()); + this.statusCode = statusCode; + this.error = error; + } + + /** + * Returns the HTTP status code associated with this exception. + * + * @return the status code + */ + public int getStatusCode() { + return statusCode; + } + + /** + * Returns the structured error body, never {@code null}. + * + * @return the structured {@link ApiError} + */ + public ApiError getError() { + return error; + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/CreateResponse.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/CreateResponse.java new file mode 100644 index 000000000000..b1e8873b8cb1 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/CreateResponse.java @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.openai.models.responses.Response; + +/** + * Wraps an OpenAI {@link Response} together with an optional {@link AgentReference} + * for the agent server protocol's create-response endpoint. + *

+ * The {@code response} field is serialized unwrapped (its properties merge into + * the top-level JSON object), while the {@code agent} field is a nested object. + * + * @param agent the agent that produced this response, or {@code null} + * @param response the OpenAI Response object + */ +public record CreateResponse(AgentReference agent, @JsonUnwrapped Response response) { +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/EventReplayStore.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/EventReplayStore.java new file mode 100644 index 000000000000..80c467685d4b --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/EventReplayStore.java @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.agentserver.api.serialization.ObjectMapperFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Process-scoped, in-memory buffer of serialized SSE events keyed by response ID, + * used to support SSE replay via {@code GET /responses/{id}?stream=true}. + * + *

+ * Each event is retained for a minimum of {@code ttl} from when it was buffered + * (default 10 minutes). The per-event TTL means individual events may be + * evicted independently — JSON GET is unaffected by replay-buffer eviction. + *

+ * Events may be appended {@linkplain #append(String, ResponseEvent) one at a time} + * (live, while the response is still streaming — supports mid-stream replay) or in + * a single batch via {@linkplain #store(String, List)}. {@linkplain #initBuffer(String)} + * registers an empty buffer so {@linkplain #hasBuffer(String)} returns {@code true} + * even before the first event arrives — required so a replay request that races + * the first emission is not mis-classified as "not created with stream=true". + */ +final class EventReplayStore { + + private static final Logger LOGGER = LoggerFactory.getLogger(EventReplayStore.class); + + /** + * Default per-event replay retention. + */ + static final Duration DEFAULT_TTL = Duration.ofMinutes(10); + + private final Duration ttl; + private final Map> buffers = new ConcurrentHashMap<>(); + + EventReplayStore() { + this(DEFAULT_TTL); + } + + EventReplayStore(Duration ttl) { + this.ttl = ttl; + } + + /** + * A single buffered event: its sequence number, name, JSON payload, and expiry. + */ + record StoredEvent(long sequenceNumber, String eventName, String data, long expiresAtMillis) { + } + + /** + * Registers an empty replay buffer for a response. Used by the streaming + * create path so {@link #hasBuffer(String)} reflects "was created with + * stream=true" before any event has been appended. + */ + void initBuffer(String responseId) { + buffers.computeIfAbsent(responseId, k -> new CopyOnWriteArrayList<>()); + } + + /** + * Appends a single event to the replay buffer (used for live mid-stream + * buffering of background streaming responses). Idempotent on the buffer + * existence — if no buffer was {@linkplain #initBuffer(String) initialised} + * first, one is created on demand. + */ + void append(String responseId, ResponseEvent event) { + if (event == null) { + return; + } + StoredEvent stored = toStoredEvent(event, buffers.getOrDefault(responseId, List.of()).size()); + if (stored == null) { + return; + } + buffers.computeIfAbsent(responseId, k -> new CopyOnWriteArrayList<>()).add(stored); + } + + /** + * Replaces the buffer for {@code responseId} with the supplied events. + * Convenience for the "buffer at terminal" pattern. + */ + void store(String responseId, List events) { + if (events == null || events.isEmpty()) { + buffers.put(responseId, new CopyOnWriteArrayList<>()); + return; + } + List stored = new CopyOnWriteArrayList<>(); + long index = 0; + for (ResponseEvent event : events) { + StoredEvent s = toStoredEvent(event, index); + if (s != null) { + stored.add(s); + } + index++; + } + buffers.put(responseId, stored); + } + + private StoredEvent toStoredEvent(ResponseEvent event, long fallbackIndex) { + ObjectMapper mapper = ObjectMapperFactory.getObjectMapper(); + try { + String data = mapper.writeValueAsString(event.streamEvent()); + long seq = fallbackIndex; + JsonNode node = mapper.readTree(data); + if (node.hasNonNull("sequence_number")) { + seq = node.get("sequence_number").asLong(fallbackIndex); + } + long expiresAt = System.currentTimeMillis() + ttl.toMillis(); + return new StoredEvent(seq, event.eventName(), data, expiresAt); + } catch (Exception e) { + LOGGER.warn("Failed to serialize replay event {}", fallbackIndex, e); + return null; + } + } + + /** + * Returns {@code true} if a replay buffer exists for the response — i.e. it was + * created with {@code stream=true} (even if all its events have since expired). + */ + boolean hasBuffer(String responseId) { + return buffers.containsKey(responseId); + } + + /** + * Returns the non-expired events for a response with {@code sequence_number} + * greater than {@code startingAfter} (when provided), in order. + */ + Optional> replay(String responseId, Integer startingAfter) { + List all = buffers.get(responseId); + if (all == null) { + return Optional.empty(); + } + long now = System.currentTimeMillis(); + long after = startingAfter != null ? startingAfter : Long.MIN_VALUE; + List live = new ArrayList<>(); + for (StoredEvent e : all) { + if (e.expiresAtMillis() <= now) { + continue; // per-event TTL eviction + } + if (e.sequenceNumber() <= after) { + continue; // starting_after cursor + } + live.add(new ResponseStreamReplay.ReplayEvent(e.sequenceNumber(), e.eventName(), e.data())); + } + return Optional.of(live); + } + + /** + * Removes any buffered events for a response (best-effort, on delete). + */ + void delete(String responseId) { + buffers.remove(responseId); + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/FoundryEnvironment.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/FoundryEnvironment.java new file mode 100644 index 000000000000..70a3b15c4711 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/FoundryEnvironment.java @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.azure.core.credential.TokenCredential; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.identity.ManagedIdentityCredentialBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; + +/** + * Provides strongly-typed access to Foundry platform environment variables + * injected by the Azure AI Foundry hosting infrastructure. + *

+ * All values are read once at class load and cached for the lifetime of the process. + * This class is a static utility and cannot be instantiated. + *

+ * Equivalent to the C# {@code FoundryEnvironment}. + */ +public final class FoundryEnvironment { + + /** + * The agent name. Sourced from {@code FOUNDRY_AGENT_NAME}. + */ + public static final String AGENT_NAME = System.getenv("FOUNDRY_AGENT_NAME"); + /** + * The agent version. Sourced from {@code FOUNDRY_AGENT_VERSION}. + */ + public static final String AGENT_VERSION = System.getenv("FOUNDRY_AGENT_VERSION"); + /** + * The Foundry project endpoint. Sourced from {@code FOUNDRY_PROJECT_ENDPOINT}, + * with fallback to {@code AZURE_AI_PROJECT_ENDPOINT}. + */ + public static final String PROJECT_ENDPOINT = resolveProjectEndpoint(); + /** + * The full ARM ID of the Foundry project. Sourced from {@code FOUNDRY_PROJECT_ARM_ID}. + */ + public static final String PROJECT_ARM_ID = System.getenv("FOUNDRY_PROJECT_ARM_ID"); + /** + * The session ID. Sourced from {@code FOUNDRY_AGENT_SESSION_ID}. + */ + public static final String SESSION_ID = System.getenv("FOUNDRY_AGENT_SESSION_ID"); + /** + * The model deployment name. Sourced from {@code MODEL_DEPLOYMENT_NAME}. + * Default: {@code "gpt-4.1-mini"}. + */ + public static final String MODEL_DEPLOYMENT_NAME = resolveWithDefault("MODEL_DEPLOYMENT_NAME", "gpt-4.1-mini"); + /** + * The Azure managed identity client ID. Sourced from {@code AZURE_CLIENT_ID}. + *

+ * When set, prefer {@code ManagedIdentityCredential} with this client ID. + * When absent, {@code DefaultAzureCredential} should be used. + */ + public static final String AZURE_CLIENT_ID = System.getenv("AZURE_CLIENT_ID"); + /** + * The Azure OpenAI endpoint derived from the project endpoint (scheme + host), + * with fallback to {@code AZURE_OPENAI_ENDPOINT} or {@code AZURE_ENDPOINT}. + *

+ * For example, if {@link #PROJECT_ENDPOINT} is + * {@code "https://account.services.ai.azure.com/api/projects/proj"}, + * this resolves to {@code "https://account.services.ai.azure.com"}. + */ + public static final String OPENAI_ENDPOINT = resolveOpenAiEndpoint(); + /** + * The HTTP listen port. Sourced from {@code PORT}. Default: 8088. + */ + public static final int PORT = resolvePort(); + /** + * The OTLP exporter endpoint. Sourced from {@code OTEL_EXPORTER_OTLP_ENDPOINT}. + */ + public static final String OTLP_ENDPOINT = System.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); + /** + * The Application Insights connection string. Sourced from {@code APPLICATIONINSIGHTS_CONNECTION_STRING}. + *

+ * Security note: This value is a credential. Avoid logging or exposing it in diagnostics. + */ + public static final String APP_INSIGHTS_CONNECTION_STRING = System.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"); + /** + * Indicates whether the process is running in a Foundry hosted environment. + * Returns {@code true} when the {@code FOUNDRY_HOSTING_ENVIRONMENT} environment variable + * is set to a non-empty value. + */ + public static final boolean IS_HOSTED = isNonEmpty(System.getenv("FOUNDRY_HOSTING_ENVIRONMENT")); + private static final Logger LOGGER = LoggerFactory.getLogger(FoundryEnvironment.class); + + private FoundryEnvironment() { + // Static utility class + } + + /** + * Resolves the appropriate {@link TokenCredential} for the current environment. + *

+ * When {@link #AZURE_CLIENT_ID} is set (typical in hosted environments with managed identity), + * returns a {@code ManagedIdentityCredential} scoped to that client ID. + * Otherwise, returns a {@code DefaultAzureCredential} for local development and + * environments without an explicit managed identity. + * + * @return a {@link TokenCredential} suitable for authenticating with Azure services + */ + public static TokenCredential resolveCredential() { + if (isNonEmpty(AZURE_CLIENT_ID)) { + LOGGER.debug("Using ManagedIdentityCredential with client ID: {}", AZURE_CLIENT_ID); + return new ManagedIdentityCredentialBuilder() + .clientId(AZURE_CLIENT_ID) + .build(); + } + LOGGER.debug("Using DefaultAzureCredential"); + return new DefaultAzureCredentialBuilder().build(); + } + + private static String resolveProjectEndpoint() { + String endpoint = System.getenv("FOUNDRY_PROJECT_ENDPOINT"); + if (isNonEmpty(endpoint)) { + return endpoint; + } + return System.getenv("AZURE_AI_PROJECT_ENDPOINT"); + } + + private static String resolveOpenAiEndpoint() { + // Derive from project endpoint (scheme + host) + if (isNonEmpty(PROJECT_ENDPOINT)) { + try { + URI uri = URI.create(PROJECT_ENDPOINT); + return uri.getScheme() + "://" + uri.getHost(); + } catch (IllegalArgumentException ignored) { + // fall through to explicit env vars + } + } + // Fallback to explicit env vars + String explicit = System.getenv("AZURE_OPENAI_ENDPOINT"); + if (isNonEmpty(explicit)) { + return explicit; + } + return System.getenv("AZURE_ENDPOINT"); + } + + private static String resolveWithDefault(String envVar, String defaultValue) { + String value = System.getenv(envVar); + return isNonEmpty(value) ? value : defaultValue; + } + + private static int resolvePort() { + String portEnv = System.getenv("PORT"); + if (portEnv == null || portEnv.isEmpty()) { + return 8088; + } + try { + int port = Integer.parseInt(portEnv); + if (port < 1 || port > 65535) { + throw new IllegalStateException( + "The PORT environment variable value '" + portEnv + "' is not a valid port number (1–65535)."); + } + return port; + } catch (NumberFormatException e) { + throw new IllegalStateException( + "The PORT environment variable value '" + portEnv + "' is not a valid port number (1–65535).", e); + } + } + + private static boolean isNonEmpty(String value) { + return value != null && !value.isEmpty(); + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/HealthApi.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/HealthApi.java new file mode 100644 index 000000000000..beaf47206b67 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/HealthApi.java @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +/** + * Interface for Kubernetes-style health probe endpoints. + *

+ * Implementations indicate whether the application is ready to serve traffic + * (readiness) and whether the process is alive (liveness). + *

+ * The default implementation always returns {@code true} for both probes. + * Override to add custom health checks (e.g., database connectivity, downstream + * service availability). + */ +public interface HealthApi { + + /** + * Returns whether the application is ready to serve requests. + *

+ * A {@code false} return value causes the orchestrator to stop routing traffic + * to this instance until it becomes ready again. + * + * @return {@code true} if ready, {@code false} otherwise + */ + default boolean isReady() { + return true; + } + + /** + * Returns whether the application process is alive and responsive. + *

+ * A {@code false} return value causes the orchestrator to restart the container. + * + * @return {@code true} if alive, {@code false} otherwise + */ + default boolean isAlive() { + return true; + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/IsolationContext.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/IsolationContext.java new file mode 100644 index 000000000000..a20d9e93d407 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/IsolationContext.java @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Carries the platform-injected isolation keys for a single request. + * The Foundry platform sets {@code x-agent-user-isolation-key} and + * {@code x-agent-chat-isolation-key} headers on every protocol request. + * These opaque partition keys let handlers scope user-private and + * conversation-shared state without inspecting user identity. + *

+ * User isolation key — the partition for data that belongs to the + * individual who initiated the request (e.g., OAuth tokens, personal memory, + * per-user preferences, cache entries). Stable for a given user across sessions. + *

+ * Chat isolation key — the partition for conversation-scoped state + * (e.g., conversation history, turn state, shared files). In a 1:1 user↔agent + * chat this equals the user isolation key. It differs only in shared-surface + * scenarios (e.g., a Teams group chat) where it represents the common partition + * all participants write to. + *

+ * Both keys are opaque, platform-generated, and scoped to the agent — data + * cannot leak between agents. Neither key is guaranteed to be present when + * running locally (outside the platform); handlers should handle {@code null} + * values gracefully (e.g., fall back to a default partition). + */ +public record IsolationContext(String userIsolationKey, String chatIsolationKey) { + + /** + * An empty {@link IsolationContext} with both keys {@code null}. + * Used when the platform headers are absent (e.g., local development). + */ + public static final IsolationContext EMPTY = new IsolationContext(null, null); + + /** + * Creates a new {@link IsolationContext} with the given keys. + * + * @param userIsolationKey the value of the {@code x-agent-user-isolation-key} header, + * or {@code null} if the header was absent. + * @param chatIsolationKey the value of the {@code x-agent-chat-isolation-key} header, + * or {@code null} if the header was absent. + */ + public IsolationContext { + } + + /** + * Extracts isolation keys from a map of HTTP headers (case-insensitive lookup). + * Returns {@link #EMPTY} when neither platform header is present. + * + * @param headers a map of header name → value (case-insensitive keys recommended). + * @return an {@link IsolationContext} with the extracted keys. + */ + public static IsolationContext fromHeaders(Map headers) { + if (headers == null || headers.isEmpty()) { + return EMPTY; + } + String userKey = normalizeHeaderValue(headers.get(PlatformHeaders.USER_ISOLATION_KEY)); + String chatKey = normalizeHeaderValue(headers.get(PlatformHeaders.CHAT_ISOLATION_KEY)); + + if (userKey == null && chatKey == null) { + return EMPTY; + } + return new IsolationContext(userKey, chatKey); + } + + /** + * Extracts client headers (those prefixed with {@code x-client-}) from a map of + * HTTP request headers. The prefix is preserved in the returned keys. + * + * @param headers a map of all request headers. + * @return an unmodifiable map of client headers (may be empty, never null). + */ + public static Map extractClientHeaders(Map headers) { + if (headers == null || headers.isEmpty()) { + return Collections.emptyMap(); + } + Map clientHeaders = new LinkedHashMap<>(); + for (Map.Entry entry : headers.entrySet()) { + String key = entry.getKey(); + if (key != null && key.toLowerCase().startsWith(PlatformHeaders.CLIENT_HEADER_PREFIX)) { + clientHeaders.put(key.toLowerCase(), entry.getValue()); + } + } + return Collections.unmodifiableMap(clientHeaders); + } + + /** + * Gets the user isolation key — the partition under which + * user-private state should be stored. + * + * @return an opaque string from {@code x-agent-user-isolation-key}, + * or {@code null} when running outside the platform. + */ + @Override + public String userIsolationKey() { + return userIsolationKey; + } + + /** + * Gets the chat isolation key — the partition under which + * conversation / shared state should be stored. + * + * @return an opaque string from {@code x-agent-chat-isolation-key}, + * or {@code null} when running outside the platform. + * In a 1:1 user↔agent chat this equals {@link #userIsolationKey ()}. + */ + @Override + public String chatIsolationKey() { + return chatIsolationKey; + } + + /** + * Returns {@code true} if both isolation keys are absent. + */ + public boolean isEmpty() { + return userIsolationKey == null && chatIsolationKey == null; + } + + private static String normalizeHeaderValue(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/Observability.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/Observability.java new file mode 100644 index 000000000000..a8aaa5b0e6da --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/Observability.java @@ -0,0 +1,404 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapGetter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +/** + * Provides OpenTelemetry instrumentation for the Azure AI Foundry Agent Server server. + *

+ * This class uses the OpenTelemetry API which is a no-op by default. It becomes active when: + *

    + *
  • The OpenTelemetry Java Agent is attached via {@code javaagent:} JVM flag
  • + *
  • The OpenTelemetry SDK is programmatically configured (e.g., via {@code opentelemetry-sdk-extension-autoconfigure})
  • + *
  • The Application Insights Java Agent is attached
  • + *
+ *

+ * The instrumentation scope is {@code "Azure.AI.AgentServer.Responses"}, matching the.NET convention. + *

+ * Environment variables (used by OTel SDK/Agent when present): + *

    + *
  • {@code OTEL_EXPORTER_OTLP_ENDPOINT} — OTLP exporter endpoint
  • + *
  • {@code OTEL_SERVICE_NAME} — service name (defaults to agent name)
  • + *
  • {@code APPLICATIONINSIGHTS_CONNECTION_STRING} — enables Application Insights export
  • + *
+ */ +public final class Observability { + + private static final Logger LOGGER = LoggerFactory.getLogger(Observability.class); + + /** + * Instrumentation scope name for the protocol, matching.NET convention. + */ + public static final String SCOPE_RESPONSES = "Azure.AI.AgentServer.Responses"; + + /** + * Instrumentation scope name for the core hosting layer. + */ + public static final String SCOPE_CORE = "Azure.AI.AgentServer.Core"; + + private static volatile Tracer responsesTracer; + private static volatile Tracer coreTracer; + + private Observability() { + // Static utility class + } + + /** + * Gets the tracer for the protocol scope. + * Uses {@link GlobalOpenTelemetry} which is auto-configured by the OTel Java Agent + * or SDK autoconfigure module. + * + * @return the protocol tracer. + */ + public static Tracer getResponsesTracer() { + if (responsesTracer == null) { + responsesTracer = GlobalOpenTelemetry.getTracer(SCOPE_RESPONSES); + } + return responsesTracer; + } + + /** + * Gets the tracer for the core hosting scope. + * + * @return the Core hosting tracer. + */ + public static Tracer getCoreTracer() { + if (coreTracer == null) { + coreTracer = GlobalOpenTelemetry.getTracer(SCOPE_CORE); + } + return coreTracer; + } + + /** + * Starts a new SERVER span for an incoming HTTP request. + * Extracts trace context from request headers (W3C traceparent) using + * the global propagator. + * + * @param spanName the span name (e.g., "POST /responses"). + * @param headers the request headers for context propagation. + * @return the started span (caller must close/end it). + */ + public static Span startServerSpan(String spanName, Map headers) { + OpenTelemetry otel = GlobalOpenTelemetry.get(); + Context extractedContext = otel.getPropagators() + .getTextMapPropagator() + .extract(Context.current(), headers, MapTextMapGetter.INSTANCE); + + return getCoreTracer() + .spanBuilder(spanName) + .setSpanKind(SpanKind.SERVER) + .setParent(extractedContext) + .startSpan(); + } + + /** + * Records standard HTTP attributes on a span. + * + * @param span the span to set attributes on. + * @param method the HTTP method (GET, POST, etc.). + * @param path the request path. + * @param statusCode the HTTP response status code. + */ + public static void setHttpAttributes(Span span, String method, String path, int statusCode) { + span.setAttribute("http.request.method", method); + span.setAttribute("url.path", path); + span.setAttribute("http.response.status_code", statusCode); + + if (statusCode >= 500) { + span.setStatus(StatusCode.ERROR, "HTTP " + statusCode); + } + } + + /** + * Starts the spec-required {@code invoke_agent {model}} span. + *

+ * Sets all required GenAI semantic-convention tags and namespaced + * {@code azure.ai.agentserver.responses.*} tags so the platform's + * trajectory UI can render the response in context. + * + * @param model resolved model name (may be {@code null} or empty) + * @param responseId the response id (e.g. {@code caresp_…}); never null/empty + * @param conversationId conversation id, or {@code null} if absent + * @param agentName agent name, or {@code null} + * @param agentVersion agent version, or {@code null} + * @param streaming whether this is a streaming invocation + * @return the started span; caller is responsible for ending it + */ + public static Span startInvokeAgentSpan( + String model, + String responseId, + String conversationId, + String agentName, + String agentVersion, + boolean streaming) { + + String spanName = (model != null && !model.isEmpty()) + ? "invoke_agent " + model + : "invoke_agent"; + + Span span = getResponsesTracer() + .spanBuilder(spanName) + .setSpanKind(SpanKind.SERVER) + .startSpan(); + + // GenAI semantic-convention identity tags + span.setAttribute("service.name", "azure.ai.agentserver"); + span.setAttribute("gen_ai.provider.name", "AzureAI Hosted Agents"); + span.setAttribute("gen_ai.operation.name", "invoke_agent"); + span.setAttribute("gen_ai.response.id", responseId); + if (model != null && !model.isEmpty()) { + span.setAttribute("gen_ai.request.model", model); + } + if (conversationId != null && !conversationId.isEmpty()) { + span.setAttribute("gen_ai.conversation.id", conversationId); + } + + String agentId; + if (agentName != null && !agentName.isEmpty()) { + agentId = agentName + ":" + (agentVersion != null ? agentVersion : ""); + span.setAttribute("gen_ai.agent.name", agentName); + if (agentVersion != null && !agentVersion.isEmpty()) { + span.setAttribute("gen_ai.agent.version", agentVersion); + } + } else { + agentId = ""; + } + span.setAttribute("gen_ai.agent.id", agentId); + + // Namespaced tags for dashboards / correlation + span.setAttribute("azure.ai.agentserver.responses.response_id", responseId); + span.setAttribute("azure.ai.agentserver.responses.conversation_id", + conversationId != null ? conversationId : ""); + span.setAttribute("azure.ai.agentserver.responses.streaming", streaming); + + String projectArmId = FoundryEnvironment.PROJECT_ARM_ID; + if (projectArmId != null && !projectArmId.isEmpty()) { + span.setAttribute("microsoft.foundry.project.id", projectArmId); + } + + return span; + } + + /** + * Records an error on an {@code invoke_agent} span per + * the spec (error tags). Sets the status to ERROR + * and adds the recommended namespaced + OTel exception tags. + */ + public static void recordInvokeAgentError(Span span, String code, String message, Throwable t) { + if (span == null) { + return; + } + if (code != null) { + span.setAttribute("azure.ai.agentserver.responses.error.code", code); + span.setAttribute("error.type", code); + } + if (message != null) { + span.setAttribute("azure.ai.agentserver.responses.error.message", message); + span.setAttribute("otel.status_description", message); + } + if (t != null) { + span.recordException(t); + } + span.setStatus(StatusCode.ERROR, message != null ? message : ""); + maybeLogRbacGuidance(t); + } + + /** + * Pattern matching a missing data-action message of the form: + * "The principal `` lacks the required data action `/`..." + * emitted by Azure Cognitive Services / OpenAI on 401 PermissionDenied. + */ + private static final java.util.regex.Pattern PRINCIPAL_LACKS_ACTION = java.util.regex.Pattern.compile( + "principal\\s+`?([0-9a-fA-F-]{36})`?\\s+lacks the required data action\\s+`?([^`\\s\"]+)`?", + java.util.regex.Pattern.CASE_INSENSITIVE); + + /** + * Backstop: catches the generic "Principal does not have access to API/Operation." + */ + private static final java.util.regex.Pattern GENERIC_PRINCIPAL_DENIED = java.util.regex.Pattern.compile( + "Principal does not have access to API/Operation", + java.util.regex.Pattern.CASE_INSENSITIVE); + + private static final java.util.Set RBAC_GUIDANCE_PRINCIPALS = + java.util.Collections.synchronizedSet(new java.util.HashSet<>()); + + /** + * Detects Azure RBAC-related authentication failures from an exception + * chain and prints a one-time human-readable remediation message that + * includes the exact {@code az role assignment create} command to run. + */ + public static void maybeLogRbacGuidance(Throwable t) { + if (t == null) { + return; + } + + Throwable cur = t; + for (int depth = 0; cur != null && depth < 8; depth++, cur = cur.getCause()) { + String msg = cur.getMessage(); + if (msg == null) { + continue; + } + java.util.regex.Matcher m = PRINCIPAL_LACKS_ACTION.matcher(msg); + if (m.find()) { + String principalId = m.group(1); + String action = m.group(2); + if (RBAC_GUIDANCE_PRINCIPALS.add("data-action:" + principalId + ":" + action)) { + logDataActionGuidance(principalId, action); + } + return; + } + if (GENERIC_PRINCIPAL_DENIED.matcher(msg).find()) { + if (RBAC_GUIDANCE_PRINCIPALS.add("generic:" + System.identityHashCode(cur))) { + logGenericPrincipalDeniedGuidance(); + } + return; + } + } + } + + private static void logDataActionGuidance(String principalId, String action) { + String role = guessRoleForAction(action); + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("==============================================================================\n"); + sb.append(" AZURE RBAC: The agent's managed identity is missing a required role.\n"); + sb.append("==============================================================================\n"); + sb.append(" Principal id : ").append(principalId).append("\n"); + sb.append(" Missing action : ").append(action).append("\n"); + if (role != null) { + sb.append(" Suggested role : \"").append(role).append("\"\n"); + } + sb.append("\n"); + sb.append(" Run the following to grant access (replace with the resource id of\n"); + sb.append(" the Cognitive Services / Foundry account the request was made against):\n"); + sb.append("\n"); + sb.append(" az role assignment create \\\n"); + sb.append(" --assignee ").append(principalId).append(" \\\n"); + sb.append(" --role \"").append(role != null ? role : "Cognitive Services OpenAI User").append("\" \\\n"); + sb.append(" --scope \n"); + sb.append("\n"); + sb.append(" Allow ~30s for the assignment to propagate, then retry.\n"); + sb.append("=============================================================================="); + LOGGER.error(sb.toString()); + } + + private static void logGenericPrincipalDeniedGuidance() { + LOGGER.error(""" + ============================================================================== + AZURE RBAC: The agent's managed identity is missing a required role. + ============================================================================== + The platform returned: "Principal does not have access to API/Operation." + The principal id is not in the message, but it is the agent's instance + identity (visible via `az agentservers show` or in the agent metadata). + + For Azure OpenAI calls against a Foundry project, the agent typically needs: + - "Cognitive Services OpenAI User" on the Cognitive Services account, AND + - "Azure AI User" on the project (scope ending in /projects/) + + Use: + az role assignment create --assignee \\ + --role "Azure AI User" --scope + =============================================================================="""); + } + + private static String guessRoleForAction(String action) { + if (action == null) { + return null; + } + String lower = action.toLowerCase(java.util.Locale.ROOT); + if (lower.contains("openai") && lower.contains("chat")) { + return "Cognitive Services OpenAI User"; + } + if (lower.contains("openai")) { + return "Cognitive Services OpenAI Contributor"; + } + if (lower.contains("cognitiveservices")) { + return "Cognitive Services User"; + } + return null; + } + + /** + * Starts the spec-required {@code tools/call {tool_name}} span + * for outbound calls to the Foundry + * Toolboxes (MCP) proxy. Use {@link #recordToolboxResult} to set the + * success/failure tags before ending the span. + * + * @param toolboxName the toolbox name used for this invocation + * @param fullToolName fully-qualified tool name, e.g. {@code everything.echo} + * @param serverLabel server-label prefix, e.g. {@code everything} + * @return the started span; caller is responsible for ending it + */ + public static Span startToolboxCallSpan(String toolboxName, String fullToolName, String serverLabel) { + Span span = getResponsesTracer() + .spanBuilder("tools/call " + fullToolName) + .setSpanKind(SpanKind.CLIENT) + .startSpan(); + if (toolboxName != null) { + span.setAttribute("azure.ai.agentserver.toolbox.name", toolboxName); + } + if (fullToolName != null) { + span.setAttribute("azure.ai.agentserver.toolbox.tool_name", fullToolName); + } + if (serverLabel != null) { + span.setAttribute("azure.ai.agentserver.toolbox.server_label", serverLabel); + } + return span; + } + + /** + * Records the success/failure outcome and latency on a {@link + * #startToolboxCallSpan} span. + */ + public static void recordToolboxResult(Span span, boolean success, long latencyMs, + String errorCode, String errorMessage, Throwable t) { + if (span == null) { + return; + } + span.setAttribute("azure.ai.agentserver.toolbox.success", success); + span.setAttribute("azure.ai.agentserver.toolbox.latency_ms", latencyMs); + if (!success) { + if (errorCode != null) { + span.setAttribute("azure.ai.agentserver.toolbox.error.code", errorCode); + } + if (errorMessage != null) { + span.setAttribute("azure.ai.agentserver.toolbox.error.message", errorMessage); + } + if (t != null) { + span.recordException(t); + } + span.setStatus(StatusCode.ERROR, errorMessage != null ? errorMessage : ""); + } + } + + /** + * TextMapGetter for extracting trace context from a {@code Map}. + */ + private enum MapTextMapGetter implements TextMapGetter> { + INSTANCE; + + @Override + public Iterable keys(Map carrier) { + return carrier.keySet(); + } + + @Override + public String get(Map carrier, String key) { + return carrier == null ? null : carrier.get(key); + } + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/PlatformHeaders.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/PlatformHeaders.java new file mode 100644 index 000000000000..72870a9ca697 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/PlatformHeaders.java @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +/** + * Defines the HTTP header names used across the AgentServer platform. + * These headers form the wire contract between the Foundry platform, + * agent containers, and downstream storage services. + *

+ * Response headers (set by the server on every response): + *

    + *
  • {@link #REQUEST_ID} — request correlation ID.
  • + *
  • {@link #SERVER_VERSION} — server SDK identity.
  • + *
  • {@link #SESSION_ID} — resolved session ID (when applicable).
  • + *
+ * Request headers (set by the platform or client): + *
    + *
  • {@link #REQUEST_ID} — client-provided correlation ID (echoed back on response).
  • + *
  • {@link #USER_ISOLATION_KEY} / {@link #CHAT_ISOLATION_KEY} — platform isolation keys.
  • + *
  • {@link #CLIENT_HEADER_PREFIX} — prefix for pass-through client headers.
  • + *
  • {@link #TRACE_PARENT} — W3C Trace Context propagation header.
  • + *
  • {@link #CLIENT_REQUEST_ID} — Azure SDK client correlation header.
  • + *
+ */ +public final class PlatformHeaders { + + /** + * The {@code x-request-id} header — carries the request correlation ID. + * On responses, the server always sets this header. + * On requests, clients may set it to provide their own correlation ID. + */ + public static final String REQUEST_ID = "x-request-id"; + + /** + * The {@code x-platform-server} header — identifies the server SDK stack + * (hosting version, protocol versions, language, and runtime). + * Set on every response by the platform header filter. + */ + public static final String SERVER_VERSION = "x-platform-server"; + + /** + * The {@code x-agent-session-id} header — the resolved session ID for the request. + * Set on responses by protocol-specific session resolution logic. + */ + public static final String SESSION_ID = "x-agent-session-id"; + + /** + * The {@code x-agent-response-id} request header — when present and non-empty, + * the container MUST use this value as the response ID instead of generating + * one. + */ + public static final String RESPONSE_ID_OVERRIDE = "x-agent-response-id"; + + /** + * The {@code x-agent-user-isolation-key} header — the platform-injected + * partition key for user-private state. + */ + public static final String USER_ISOLATION_KEY = "x-agent-user-isolation-key"; + + /** + * The {@code x-agent-chat-isolation-key} header — the platform-injected + * partition key for conversation-scoped state. + */ + public static final String CHAT_ISOLATION_KEY = "x-agent-chat-isolation-key"; + + /** + * The prefix {@code x-client-} for pass-through client headers. + * All request headers starting with this prefix are extracted and forwarded + * to the handler via the response context. + */ + public static final String CLIENT_HEADER_PREFIX = "x-client-"; + + /** + * The {@code traceparent} header — W3C Trace Context propagation header. + * Used for distributed tracing correlation on outbound storage requests. + */ + public static final String TRACE_PARENT = "traceparent"; + + /** + * The {@code x-ms-client-request-id} header — Azure SDK client correlation header. + * Logged for diagnostic correlation with upstream Azure SDK callers. + */ + public static final String CLIENT_REQUEST_ID = "x-ms-client-request-id"; + + private PlatformHeaders() { + // Static constants class + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/RequestMetadata.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/RequestMetadata.java new file mode 100644 index 000000000000..ed5338ba96a5 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/RequestMetadata.java @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +/** + * Carries HTTP-level metadata extracted from the incoming request that should be + * propagated to the {@link ResponseContext}. This includes platform headers + * (isolation keys, client headers), query parameters, and request ID. + *

+ * Framework adapters (Spring, JAX-RS) create instances from the HTTP request + * and pass them to the API layer via {@link ResponsesApi} methods. + */ +public final class RequestMetadata { + + /** + * Empty metadata instance with no headers or parameters. + * Used when no HTTP context is available (e.g., unit tests, direct API calls). + */ + public static final RequestMetadata EMPTY = new RequestMetadata( + IsolationContext.EMPTY, Collections.emptyMap(), Collections.emptyMap(), null, null); + + private final IsolationContext isolation; + private final Map clientHeaders; + private final Map queryParameters; + private final String requestId; + private final String responseIdOverride; + + /** + * Creates a new {@link RequestMetadata} instance. + * + * @param isolation the isolation context (non-null). + * @param clientHeaders the forwarded {@code x-client-*} headers (non-null). + * @param queryParameters the query parameters (non-null). + * @param requestId the request ID for correlation, or {@code null}. + * @param responseIdOverride the value of the {@code x-agent-response-id} request + * header, or {@code null} when absent / empty. + */ + public RequestMetadata( + IsolationContext isolation, + Map clientHeaders, + Map queryParameters, + String requestId, + String responseIdOverride) { + this.isolation = Objects.requireNonNull(isolation, "isolation must not be null"); + this.clientHeaders = Objects.requireNonNull(clientHeaders, "clientHeaders must not be null"); + this.queryParameters = Objects.requireNonNull(queryParameters, "queryParameters must not be null"); + this.requestId = requestId; + this.responseIdOverride = (responseIdOverride == null || responseIdOverride.isEmpty()) + ? null : responseIdOverride; + } + + /** + * Backwards-compatible 4-arg constructor for callers that do not carry an + * {@code x-agent-response-id} override (e.g. direct API tests). + */ + public RequestMetadata( + IsolationContext isolation, + Map clientHeaders, + Map queryParameters, + String requestId) { + this(isolation, clientHeaders, queryParameters, requestId, null); + } + + /** + * Gets the platform-injected isolation keys. + */ + public IsolationContext getIsolation() { + return isolation; + } + + /** + * Gets the forwarded client headers (prefixed with {@code x-client-}). + */ + public Map getClientHeaders() { + return clientHeaders; + } + + /** + * Gets the query parameters from the HTTP request. + */ + public Map getQueryParameters() { + return queryParameters; + } + + /** + * Gets the request ID for correlation. + */ + public String getRequestId() { + return requestId; + } + + /** + * Gets the value of the {@code x-agent-response-id} request header — when + * non-null/non-empty, this overrides the generated response ID. + * + * @return the override, or {@code null} when the header was absent or empty. + */ + public String getResponseIdOverride() { + return responseIdOverride; + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseBuilder.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseBuilder.java new file mode 100644 index 000000000000..cc46e751b2af --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseBuilder.java @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.microsoft.agentserver.api.implementation.IdGenerator; +import com.openai.core.JsonMissing; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseOutputText; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ToolChoiceOptions; + +import java.util.List; + +/** + * Utility class for constructing {@link Response} objects from output content. + *

+ * Uses {@link IdGenerator} for consistent Foundry-format IDs across the codebase. + */ +public final class ResponseBuilder { + + private static final String DEFAULT_MODEL = "gpt-4o"; + + private ResponseBuilder() { + // Static utility class — do not instantiate. + } + + /** + * Extracts the model name from the request. + * Returns the model specified in the request, or {@value #DEFAULT_MODEL} as a default fallback. + * + * @param request the create response request + * @return the model name + */ + public static String getModelName(AgentServerCreateResponse request) { + try { + ResponseCreateParams.Body body = request.responseCreateParams(); + if (body.model().isPresent() && + body.model().get()._json().isPresent() && + body.model().get()._json().get().asString().isPresent()) { + return body.model().get()._json().get().asString().get().toString(); + } + } catch (Exception e) { + // Fall through to default + } + return DEFAULT_MODEL; + } + + /** + * Constructs a complete {@link Response} wrapping the given output text. + * + * @param createResponse the original create request (used to extract model name) + * @param responseOutputText the text content to include in the response + * @return a fully-formed Response object + */ + public static Response convertOutputToResponse( + AgentServerCreateResponse createResponse, + ResponseOutputText responseOutputText) { + // IDs use a fresh partition key here; the API layer + // (AgentServerResponsesApi.normalizeIdsAndStamp) re-partitions all + // child IDs to match the resolved response ID before persistence. + IdGenerator idGen = new IdGenerator(null); + String responseId = idGen.generateResponseId(); + String messageId = idGen.generateMessageItemId(); + + ResponseOutputMessage.Content responseOutputMessage = ResponseOutputMessage.Content.ofOutputText(responseOutputText); + + ResponseOutputMessage message = ResponseOutputMessage.builder() + .addContent(responseOutputMessage) + .id(messageId) + .status(ResponseOutputMessage.Status.COMPLETED) + .build(); + + Response.Builder builder = Response.builder() + .id(responseId) + .createdAt(System.currentTimeMillis() / 1000.0) + .addOutput(ResponseOutputItem.ofMessage(message)) + .model(getModelName(createResponse)) + .parallelToolCalls(false) + .tools(List.of()) + .status(ResponseStatus.COMPLETED) + .toolChoice(ToolChoiceOptions.AUTO) + .error(JsonMissing.of()) + .incompleteDetails(JsonMissing.of()) + .instructions(JsonMissing.of()) + .metadata(JsonMissing.of()) + .temperature(JsonMissing.of()) + .topP(JsonMissing.of()); + + // Only echo a conversation id when the client supplied one. The platform + // storage backend rejects responses that reference a conversation_id it + // does not already know about ("conv_… not found"). + createResponse.responseCreateParams().conversation().ifPresent(conv -> { + if (conv.isId()) { + builder.conversation(Response.Conversation.builder().id(conv.asId()).build()); + } + }); + + return builder.build(); + } + +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseContext.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseContext.java new file mode 100644 index 000000000000..b2fcd351c6d7 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseContext.java @@ -0,0 +1,325 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.fasterxml.jackson.databind.JsonNode; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseItem; +import com.openai.models.responses.ResponseOutputItem; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +public interface ResponseContext { + /** + * Creates a new {@link Builder} for constructing {@link ResponseContext} instances. + * + * @return a new builder instance. + */ + static Builder builder() { + return new Builder(); + } + + /** + * Gets the unique response identifier. + */ + String getResponseId(); + + /** + * Gets whether the server is shutting down. + * Handlers can use this to distinguish shutdown from explicit cancel or client disconnect. + */ + boolean isShutdownRequested(); + + /** + * Gets whether this request has been cancelled (e.g., client disconnected). + * Handlers should poll this periodically during long-running operations + * and abort gracefully when it returns {@code true}. + * + * @return {@code true} if the request has been cancelled. + */ + boolean isCancelled(); + + /** + * Requests cancellation of this response context. + * Called by the hosting infrastructure when the client disconnects + * or an explicit cancel is received. + */ + void cancel(); + + + /** + * Gets the full raw JSON request body. + * Allows handlers to access custom or extension fields that are not part of the typed model. + * Returns {@code null} when no raw body is available (e.g., test-constructed contexts). + */ + JsonNode getRawBody(); + + /** + * Resolves and returns the input items for the current request. + * Inline items are converted to {@link ResponseOutputItem} instances; + * item references are resolved via the provider. Results are cached + * after the first call. + * + * @return a future containing the resolved input items. + */ + CompletableFuture> getInputItemsAsync(); + + /** + * Resolves and returns the conversation history items for the current request. + * History is fetched from the provider using {@code previous_response_id} and/or + * {@code conversation} context. Items are returned as {@link ResponseItem} in + * ascending (chronological) order, naturally carrying role information. + * Results are cached after the first call. + * + * @return a future containing the resolved history items, or an empty list if no conversation context exists. + */ + CompletableFuture> getHistoryAsync(); + + /** + * Gets the platform-injected isolation keys for this request. + * Handlers use these opaque partition keys to scope user-private and + * conversation-shared state. Returns {@link IsolationContext#EMPTY} + * when the platform headers are absent (e.g., local development). + * + * @return the isolation context for this request. + */ + default IsolationContext getIsolation() { + return IsolationContext.EMPTY; + } + + /** + * Gets the forwarded client headers (those prefixed with {@code x-client-}) + * from the original HTTP request. + * + * @return an unmodifiable map of client header name → value (may be empty, never null). + */ + default Map getClientHeaders() { + return Collections.emptyMap(); + } + + /** + * Gets the query parameters from the original HTTP request. + * + * @return an unmodifiable map of parameter name → value (may be empty, never null). + */ + default Map getQueryParameters() { + return Collections.emptyMap(); + } + + /** + * Gets the request ID for this request (from the {@code x-request-id} header + * or auto-generated). Useful for correlation in logs and downstream calls. + * + * @return the request ID, or {@code null} if not set. + */ + default String getRequestId() { + return null; + } + + /** + * Gets the resolved session ID for this request, per the Responses Protocol + * Spec. + *

+ * Priority chain: + *

    + *
  1. {@code request.agent_session_id} payload field (client-supplied).
  2. + *
  3. {@code FOUNDRY_AGENT_SESSION_ID} environment variable (platform-supplied + * when running in a Foundry hosted container).
  4. + *
  5. Deterministic SHA-256 derivation from agent identity + partition source + * ({@code conversation_id} or {@code previous_response_id}), or a random + * value when no conversational context exists.
  6. + *
+ * Handlers should prefer this value over reading + * {@code FoundryEnvironment.SESSION_ID} directly: it is request-scoped (so it + * stays correct if the hosting model ever supports multiple sessions per + * container) and honors client-supplied {@code agent_session_id} overrides. + * + * @return the resolved session ID (never null/empty when the context was + * created by the platform; may be {@code null} for test-constructed + * contexts that did not supply one). + */ + default String getSessionId() { + return null; + } + + /** + * Builder for constructing {@link ResponseContext} instances. + *

+ * All parameters are set via fluent setters. Required parameters + * ({@code responseId}, {@code provider}, {@code request}) are validated + * when {@link #build()} is called. + * + *

{@code
+     * ResponseContext ctx = ResponseContext.builder()
+     *     .responseId(responseId)
+     *     .provider(provider)
+     *     .request(params)
+     *     .rawBody(jsonNode)
+     *     .historyLimit(50)
+     *     .build();
+     * }
+ */ + final class Builder { + private String responseId; + private ResponsesProvider provider; + private ResponseCreateParams.Body request; + private JsonNode rawBody; + private Integer historyLimit; + private IsolationContext isolation; + private Map clientHeaders; + private Map queryParameters; + private String requestId; + private String sessionId; + + Builder() { + } + + /** + * Sets the unique response identifier (required). + * + * @param responseId the response identifier. + * @return this builder. + */ + public Builder responseId(String responseId) { + this.responseId = responseId; + return this; + } + + /** + * Sets the responses provider for resolving item references and history (required). + * + * @param provider the responses' provider. + * @return this builder. + */ + public Builder provider(ResponsesProvider provider) { + this.provider = provider; + return this; + } + + /** + * Sets the create-response request body containing input items (required). + * + * @param request the request body. + * @return this builder. + */ + public Builder request(ResponseCreateParams.Body request) { + this.request = request; + return this; + } + + /** + * Sets the create-response request, extracting its body (required). + * + * @param request the create-response request. + * @return this builder. + */ + public Builder request(ResponseCreateParams request) { + Objects.requireNonNull(request, "request must not be null"); + this.request = request._body(); + return this; + } + + /** + * Sets the full raw JSON request body. + * + * @param rawBody the raw JSON body, or {@code null} if not available. + * @return this builder. + */ + public Builder rawBody(JsonNode rawBody) { + this.rawBody = rawBody; + return this; + } + + /** + * Sets the maximum number of history items to fetch. + * Defaults to {@link AgentServerResponseContext#DEFAULT_FETCH_HISTORY_COUNT} if not set. + * + * @param historyLimit the maximum number of history items. + * @return this builder. + */ + public Builder historyLimit(int historyLimit) { + this.historyLimit = historyLimit; + return this; + } + + /** + * Sets the platform-injected isolation context (optional). + * Defaults to {@link IsolationContext#EMPTY} if not set. + * + * @param isolation the isolation context. + * @return this builder. + */ + public Builder isolation(IsolationContext isolation) { + this.isolation = isolation; + return this; + } + + /** + * Sets the forwarded client headers (optional). + * These are headers prefixed with {@code x-client-} from the HTTP request. + * + * @param clientHeaders the client headers map. + * @return this builder. + */ + public Builder clientHeaders(Map clientHeaders) { + this.clientHeaders = clientHeaders; + return this; + } + + /** + * Sets the query parameters from the HTTP request (optional). + * + * @param queryParameters the query parameter map. + * @return this builder. + */ + public Builder queryParameters(Map queryParameters) { + this.queryParameters = queryParameters; + return this; + } + + /** + * Sets the request ID for correlation (optional). + * + * @param requestId the request ID. + * @return this builder. + */ + public Builder requestId(String requestId) { + this.requestId = requestId; + return this; + } + + /** + * Sets the resolved session ID for this request (optional). + *

+ * Typically set by the API layer from {@code SessionIdResolver}. Handlers + * read it back via {@link ResponseContext#getSessionId()}. + * + * @param sessionId the resolved session ID, or {@code null} if not available. + * @return this builder. + */ + public Builder sessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** + * Builds and returns a new {@link ResponseContext} instance. + * + * @return the constructed {@link ResponseContext}. + * @throws NullPointerException if {@code responseId}, {@code provider}, or {@code request} is null. + */ + public ResponseContext build() { + Objects.requireNonNull(responseId, "responseId must not be null"); + Objects.requireNonNull(provider, "provider must not be null"); + Objects.requireNonNull(request, "request must not be null"); + return new AgentServerResponseContext( + responseId, provider, request, rawBody, historyLimit, + isolation, clientHeaders, queryParameters, requestId, sessionId); + } + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseEvent.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseEvent.java new file mode 100644 index 000000000000..00c8a933b872 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseEvent.java @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.openai.models.responses.ResponseStreamEvent; + +/** + * Wraps a {@link ResponseStreamEvent} together with its SSE event name. + * Produced by {@link AgentServerResponseEventStream} emit methods and consumed by the SSE transport layer. + * + * @param eventName the SSE event name, e.g. {@code "response.created"}, {@code "response.output_text.delta"} + * @param streamEvent the wrapped OpenAI SDK stream event union + */ +public record ResponseEvent(String eventName, ResponseStreamEvent streamEvent) { +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseEventStream.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseEventStream.java new file mode 100644 index 000000000000..5ae92de1c6ee --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseEventStream.java @@ -0,0 +1,338 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCodeInterpreterToolCall; +import com.openai.models.responses.ResponseCustomToolCall; +import com.openai.models.responses.ResponseError; +import com.openai.models.responses.ResponseFileSearchToolCall; +import com.openai.models.responses.ResponseFunctionWebSearch; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseReasoningItem; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ResponseStreamEvent; +import com.openai.models.responses.ResponseUsage; + +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +public interface ResponseEventStream { + + /** + * Creates a new {@link Builder} for constructing {@link ResponseEventStream} instances. + * + * @return a new builder instance. + */ + static Builder builder() { + return new Builder(); + } + + /** + * Creates a new event stream from a request and context. + * + * @param context the response context (required). + * @param request the create-response request (required). + * @return a new {@link ResponseEventStream}. + */ + static ResponseEventStream create(ResponseContext context, AgentServerCreateResponse request) { + return AgentServerResponseEventStream.create(context, request); + } + + List getEvents(); + + Response getResponse(); + + void subscribe(Consumer onEvent, Consumer onFailure, Runnable onComplete); + + void awaitSubscription() throws InterruptedException; + + ResponseEventStream emitQueued(); + + ResponseEventStream emitCreated(); + + ResponseEventStream emitCreated(ResponseStatus status); + + ResponseEventStream emitInProgress(); + + ResponseEventStream emitCompleted(); + + ResponseEventStream emitCompleted(ResponseUsage usage); + + ResponseEventStream emitFailed(); + + ResponseEventStream emitFailed(ResponseError.Code code, String message); + + ResponseEventStream emitFailed(ResponseError.Code code, String message, ResponseUsage usage); + + ResponseEventStream emitIncomplete(); + + ResponseEventStream emitIncomplete(Response.IncompleteDetails.Reason reason); + + ResponseEventStream emitIncomplete(Response.IncompleteDetails.Reason reason, ResponseUsage usage); + + ResponseEventStream addOutputMessage(Consumer config); + + ResponseEventStream addOutputFunctionCall(Consumer config); + + ResponseEventStream addOutputReasoningItem(Consumer> config); + + ResponseEventStream addOutputFileSearchCall(Consumer> config); + + ResponseEventStream addOutputWebSearchCall(Consumer> config); + + ResponseEventStream addOutputCodeInterpreterCall( + Consumer> config); + + ResponseEventStream addOutputImageGenCall( + Consumer> config); + + ResponseEventStream addOutputMcpCall(Consumer> config); + + ResponseEventStream addOutputMcpListTools( + Consumer> config); + + ResponseEventStream addOutputCustomToolCall(Consumer> config); + + ResponseEventStream addOutputItem(String itemId, Consumer> config); + + ResponseEventStream emit(ResponseEvent event); + + ResponseEventStream forwardUpstream(java.util.stream.Stream upstreamEvents); + + void trackCompletedOutputItem(ResponseOutputItem outputItem, long outputIdx); + + void addEvent(ResponseEvent responseEvent); + + long nextSequenceNumber(); + + // ── Nested builder interfaces ─────────────────────────────── + + /** + * Fluent builder for a generic output item. Provides methods to emit + * {@code output_item.added} and {@code output_item.done} events. + * + * @param the output item model type + */ + interface OutputItemBuilder { + + /** + * Emits a {@code response.output_item.added} event for this item. + * + * @param item the output item to emit. + * @return this builder. + */ + OutputItemBuilder emitAdded(T item); + + /** + * Emits a {@code response.output_item.done} event for this item + * and tracks it in the parent stream's output list. + * + * @param item the completed output item. + * @return this builder. + */ + OutputItemBuilder emitDone(T item); + + /** + * Returns the auto-generated item ID for this output item. + */ + String getItemId(); + } + + /** + * Fluent builder for a function call output item. Provides convenience methods + * for the common function call lifecycle: added, argument deltas, argument done, + * and item done. + * + *

{@code
+     * stream.addOutputFunctionCall(func -> func
+     *     .emitAdded("get_weather", "call_123")
+     *     .emitArgumentsDelta("{\"location\":")
+     *     .emitArgumentsDelta("\"Seattle\"}")
+     *     .emitArgumentsDone("get_weather", "{\"location\":\"Seattle\"}")
+     *     .emitDone());
+     * }
+ */ + interface OutputFunctionCallBuilder { + + /** + * Emits a {@code response.output_item.added} event with an in-progress function call. + * + * @param name the function name (e.g. "get_weather"). + * @param callId the call ID for this function invocation. + * @return this builder. + */ + OutputFunctionCallBuilder emitAdded(String name, String callId); + + /** + * Emits a {@code response.function_call_arguments.delta} event and accumulates + * the arguments text. + * + * @param delta the argument text chunk. + * @return this builder. + */ + OutputFunctionCallBuilder emitArgumentsDelta(String delta); + + /** + * Emits a {@code response.function_call_arguments.done} event with the final + * function name and complete arguments string. + * + * @param name the function name. + * @param arguments the complete arguments JSON string. + * @return this builder. + */ + OutputFunctionCallBuilder emitArgumentsDone(String name, String arguments); + + /** + * Emits a {@code response.output_item.done} event with the completed function call + * and tracks it in the parent stream's output list. + * + * @return this builder. + */ + OutputFunctionCallBuilder emitDone(); + + /** + * Returns the auto-generated item ID for this function call. + */ + String getItemId(); + } + + /** + * Fluent builder for a message output item. Provides convenience methods + * for the common message lifecycle and lexically scoped text-part builders. + */ + interface OutputMessageBuilder { + + /** + * Emits a {@code response.output_item.added} event with an in-progress message. + * + * @return this builder. + */ + OutputMessageBuilder emitAdded(); + + /** + * Adds a text content part. The consumer configures delta emissions within + * a lexically scoped block. + * + * @param config consumer that configures the text part builder. + * @return this builder. + */ + OutputMessageBuilder addTextPart(Consumer config); + + /** + * Convenience method that emits a complete text message in one call. + * Equivalent to: + *
{@code
+         * msg.emitAdded()
+         *    .addTextPart(text -> text.emitAdded().emitDelta(content).emitDone(content))
+         *    .emitDone();
+         * }
+ * + * @param content the full text content to emit as a single delta and done event. + * @return this builder. + */ + OutputMessageBuilder outputItemMessage(String content); + + /** + * Emits a {@code response.output_item.done} event with the completed message + * and tracks it in the parent stream's output list. + * + * @return this builder. + */ + OutputMessageBuilder emitDone(); + + /** + * Returns the auto-generated item ID for this message. + */ + String getItemId(); + } + + /** + * Fluent builder for a text content part within a message. + * Tracks accumulated delta text for the final {@code content_part.done} event. + */ + interface TextPartBuilder { + + /** + * Emits a {@code response.content_part.added} event with an empty text part. + * + * @return this builder. + */ + TextPartBuilder emitAdded(); + + /** + * Emits a {@code response.output_text.delta} event and accumulates the text. + * + * @param delta the text chunk. + * @return this builder. + */ + TextPartBuilder emitDelta(String delta); + + /** + * Emits a {@code response.output_text.done} event with the final text. + * + * @param text the final text value. + * @return this builder. + */ + TextPartBuilder emitDone(String text); + } + + // ── ResponseEventStream.Builder ───────────────────────────── + + /** + * Builder for constructing {@link ResponseEventStream} instances. + *

+ * All parameters are set via fluent setters. Required parameters + * ({@code context}, {@code request}) are validated when {@link #build()} is called. + * + *

{@code
+     * ResponseEventStream stream = ResponseEventStream.builder()
+     *     .context(responseContext)
+     *     .request(createResponse)
+     *     .build();
+     * }
+ */ + final class Builder { + private ResponseContext context; + private AgentServerCreateResponse request; + + Builder() { + } + + /** + * Sets the response context (required). + * + * @param context the response context. + * @return this builder. + */ + public Builder context(ResponseContext context) { + this.context = context; + return this; + } + + /** + * Sets the create-response request (required). + * + * @param request the create-response request. + * @return this builder. + */ + public Builder request(AgentServerCreateResponse request) { + this.request = request; + return this; + } + + /** + * Builds and returns a new {@link ResponseEventStream} instance. + * + * @return the constructed {@link ResponseEventStream}. + * @throws NullPointerException if {@code context} or {@code request} is null. + */ + public ResponseEventStream build() { + Objects.requireNonNull(context, "context must not be null"); + Objects.requireNonNull(request, "request must not be null"); + return AgentServerResponseEventStream.create(context, request); + } + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseHandler.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseHandler.java new file mode 100644 index 000000000000..0299215bd4e3 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseHandler.java @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +/** + * Handler interface for processing incoming response requests. + *

+ * Implementors must override at least one of the two methods: + *

    + *
  • {@link #createAsync} — for streaming responses (SSE)
  • + *
  • {@link #createResponse} — for non-streaming (synchronous) responses
  • + *
+ */ +public interface ResponseHandler { + + /** + * Creates a streaming response from a {@link AgentServerCreateResponse}. + * Used by the server adapter when dispatching incoming streaming HTTP requests. + * + * @param responseContext the context for this response (ID, provider, history) + * @param request the deserialized create-response request + * @return an event stream that produces SSE events as the response is generated + * @throws UnsupportedOperationException if the handler does not support streaming + */ + default ResponseEventStream createAsync( + ResponseContext responseContext, + AgentServerCreateResponse request + ) { + throw new UnsupportedOperationException( + "Streaming responses (createAsync) are not implemented by this handler. " + + "Override createAsync() to support streaming."); + } + + /** + * Creates a synchronous (non-streaming) response from a {@link AgentServerCreateResponse}. + * Used by the server adapter when dispatching incoming non-streaming HTTP requests. + * + * @param responseContext the context for this response (ID, provider, history) + * @param request the deserialized create-response request + * @return the completed response + * @throws UnsupportedOperationException if the handler does not support synchronous responses + */ + default CreateResponse createResponse( + ResponseContext responseContext, + AgentServerCreateResponse request + ) { + throw new UnsupportedOperationException( + "Synchronous responses (createResponse) are not implemented by this handler. " + + "Override createResponse() to support non-streaming."); + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseStreamReplay.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseStreamReplay.java new file mode 100644 index 000000000000..8a0788af7032 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseStreamReplay.java @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import java.util.List; + +/** + * The result of replaying the stored Server-Sent Events for a background + * streaming response. + *

+ * Each {@link ReplayEvent} carries the SSE event name and the JSON payload exactly + * as it was emitted (including the {@code sequence_number} field). The adapter + * writes them to the wire as {@code event: {name}\ndata: {data}\n\n}. + * + * @param events the ordered list of replayable events (already filtered by the + * {@code starting_after} cursor when provided) + */ +public record ResponseStreamReplay(List events) { + + /** + * A single replayable SSE event. + * + * @param sequenceNumber the 0-based monotonically increasing sequence number + * @param eventName the SSE event name, e.g. {@code "response.created"} + * @param data the JSON payload (includes {@code sequence_number}) + */ + public record ReplayEvent(long sequenceNumber, String eventName, String data) { + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponsesApi.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponsesApi.java new file mode 100644 index 000000000000..6f8162692370 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponsesApi.java @@ -0,0 +1,292 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.microsoft.agentserver.api.implementation.FoundryStorageProvider; +import com.microsoft.agentserver.api.implementation.InMemoryResponseProvider; +import com.openai.models.responses.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Objects; + +/** + * Defines the contract for the OpenAI Responses API as hosted by the + * Azure AI Foundry agent server infrastructure. + *

+ * This interface is framework-agnostic — it uses only domain types and can be + * implemented without any dependency on a specific HTTP framework (JAX-RS, Spring, etc.). + * Framework-specific adapters (e.g., {@code java-agent-server-api-jaxrs}) provide + * the HTTP transport layer on top of this interface. + */ +public interface ResponsesApi { + + /** + * Creates a new {@link Builder} for constructing {@link ResponsesApi} instances. + * + * @return a new builder instance. + */ + static Builder builder() { + return new Builder(); + } + + static ResponsesApi create(ResponseHandler handler) { + return ResponsesApi.builder().responseHandler(handler).build(); + } + + /** + * Creates a synchronous (non-streaming) model response. + * + * @param createResponse the create-response request + * @return the completed response + * @throws ApiException if the request is invalid or processing fails + */ + CreateResponse createResponse(AgentServerCreateResponse createResponse) throws ApiException; + + /** + * Creates a synchronous (non-streaming) model response with HTTP request metadata. + * The metadata (isolation keys, client headers, query parameters, request ID) is + * propagated to the {@link ResponseContext} available to handlers. + * + * @param createResponse the create-response request + * @param metadata the HTTP request metadata + * @return the completed response + * @throws ApiException if the request is invalid or processing fails + */ + default CreateResponse createResponse(AgentServerCreateResponse createResponse, RequestMetadata metadata) throws ApiException { + return createResponse(createResponse); + } + + /** + * Creates a streaming model response, returning an event stream that produces + * Server-Sent Events as the response is generated. + * + * @param createResponse the create-response request + * @return the event stream for streaming delivery + * @throws ApiException if the request is invalid or processing fails + */ + ResponseEventStream createStreamingResponse(AgentServerCreateResponse createResponse) throws ApiException; + + /** + * Creates a streaming model response with HTTP request metadata. + * The metadata (isolation keys, client headers, query parameters, request ID) is + * propagated to the {@link ResponseContext} available to handlers. + * + * @param createResponse the create-response request + * @param metadata the HTTP request metadata + * @return the event stream for streaming delivery + * @throws ApiException if the request is invalid or processing fails + */ + default ResponseEventStream createStreamingResponse(AgentServerCreateResponse createResponse, RequestMetadata metadata) throws ApiException { + return createStreamingResponse(createResponse); + } + + /** + * Retrieves a previously stored response by ID. + * + * @param responseId the response identifier + * @param include optional list of fields to include + * @return the stored response + * @throws ApiException if the response is not found or retrieval fails + */ + Response getResponse(String responseId, List include) throws ApiException; + + /** + * Variant that accepts the inbound {@link RequestMetadata} so the API layer + * can forward platform isolation headers to the storage backend. Required + * for {@code FoundryStorageProvider}, which partitions stored responses by + * isolation key — a GET without the matching headers returns 404 even when + * the response exists. + * + * @param responseId the response identifier + * @param include optional list of fields to include + * @param metadata the inbound request metadata (may be {@link RequestMetadata#EMPTY}) + * @return the stored response + * @throws ApiException if the response is not found or retrieval fails + */ + default Response getResponse(String responseId, List include, RequestMetadata metadata) throws ApiException { + return getResponse(responseId, include); + } + + /** + * Cancels a background response that is still queued or in progress. + *

+ * Behaviour follows the API spec: + *

    + *
  • If the response does not exist (or was never persisted) → {@link ApiException} 404.
  • + *
  • If the response was not created with {@code background=true} → {@link ApiException} + * 400 ({@code "Cannot cancel a synchronous response."}). The background check happens + * first, before status checks.
  • + *
  • If the response is already {@code cancelled} → returns it unchanged (idempotent).
  • + *
  • If the response is in a terminal state ({@code completed}/{@code failed}/{@code incomplete}) + * → {@link ApiException} 400 with the corresponding message.
  • + *
  • If the response is {@code queued} or {@code in_progress} → it winds down to + * {@code cancelled} with its output cleared and the cancelled + * {@link Response} is returned.
  • + *
+ * + * @param responseId the response identifier + * @return the cancelled (or already-cancelled) response + * @throws ApiException with status 404 if not found, or 400 if the response cannot be cancelled + */ + Response cancelResponse(String responseId) throws ApiException; + + /** + * Signals that the HTTP client disconnected from a non-background response + * before processing completed. For non-background responses the + * implementation winds down to {@code cancelled} (output cleared, + * cancelled snapshot persisted if {@code store=true}) and prevents the + * normal terminal persistence from overwriting it. For background responses + * this is a no-op. + *

+ * Safe to call unconditionally from the adapter on disconnect detection — + * if no in-flight execution matches the ID, the call is a no-op. + * + * @param responseId the in-flight response identifier whose client disconnected + */ + default void signalClientDisconnected(String responseId) { + // No-op default keeps the interface backward compatible for custom impls. + } + + /** + * Replays the stored SSE event sequence for a background streaming response. + * Triggered by + * {@code GET /responses/{id}?stream=true}. + *

+ * Preconditions: the response must have been created with + * {@code store=true}, {@code background=true} and {@code stream=true}. + *

    + *
  • Not found / {@code store=false} → {@link ApiException} 404.
  • + *
  • {@code background=false} → {@link ApiException} 400 + * ({@code "This response cannot be streamed because it was not created with background=true."}).
  • + *
  • {@code stream=false} → {@link ApiException} 400 + * ({@code "This response cannot be streamed because it was not created with stream=true."}).
  • + *
+ * Otherwise returns the events with {@code sequence_number > startingAfter}; + * if {@code startingAfter} is at or beyond the last sequence number, + * the returned list is empty. + * + * @param responseId the response identifier + * @param startingAfter replay only events with a greater sequence number, or {@code null} for all + * @return the (possibly empty) replay event sequence + * @throws ApiException 404 if not found/not stored, 400 if not replayable + */ + ResponseStreamReplay replayResponseStream(String responseId, Integer startingAfter) throws ApiException; + + /** + * Deletes a previously stored response by ID. + * + * @param responseId the response identifier + * @throws ApiException if deletion fails + */ + void deleteResponse(String responseId) throws ApiException; + + /** + * Returns a paginated list of input items for a given response. + * + * @param responseId the response identifier + * @param limit maximum number of items to return (default 20, range 1-100) + * @param order sort order ({@code "asc"} or {@code "desc"}) + * @param after cursor for forward pagination — return items after this item ID + * @param before cursor for backward pagination — return items before this item ID + * @param include optional list of fields to include + * @return the paginated item list + * @throws ApiException if the response is not found or retrieval fails + */ + AgentServerResponseItemList listInputItems( + String responseId, + Integer limit, + String order, + String after, + String before, + List include) throws ApiException; + + /** + * Builder for constructing {@link ResponsesApi} instances backed by + * {@link AgentServerResponsesApi}. + *

+ * All parameters are set via fluent setters. The {@code responseHandler} is required + * and validated when {@link #build()} is called. The {@code provider} is optional — + * if not set, an appropriate provider is resolved automatically based on the + * hosting environment. + * + *

{@code
+     * ResponsesApi api = ResponsesApi.builder()
+     *     .responseHandler(myHandler)
+     *     .provider(myProvider)
+     *     .build();
+     * }
+ */ + final class Builder { + private static final Logger LOGGER = LoggerFactory.getLogger(ResponsesApi.class); + + private ResponseHandler responseHandler; + private ResponsesProvider provider; + + Builder() { + } + + private static ResponsesProvider resolveProvider() { + // : when FOUNDRY_HOSTING_ENVIRONMENT is set, the SDK MUST auto-activate + // the Foundry state provider for durable, multi-instance persistence. + if (FoundryEnvironment.IS_HOSTED) { + try { + FoundryStorageProvider provider = FoundryStorageProvider.fromEnvironment(); + LOGGER.info("Hosted environment detected (FOUNDRY_HOSTING_ENVIRONMENT set). " + + "Using FoundryStorageProvider."); + return provider; + } catch (RuntimeException e) { + // Hosted but FOUNDRY_PROJECT_ENDPOINT is missing/invalid — log and fall + // back to in-memory rather than failing startup, so the container can + // still serve health probes for diagnosis. + LOGGER.error("Failed to initialise FoundryStorageProvider in hosted mode; " + + "falling back to InMemoryResponseProvider. Cause: {}", e.getMessage()); + } + } else { + LOGGER.debug("Local environment detected. Using InMemoryResponseProvider."); + } + return new InMemoryResponseProvider(); + } + + /** + * Sets the response handler that generates responses (required). + * + * @param responseHandler the response handler. + * @return this builder. + */ + public Builder responseHandler(ResponseHandler responseHandler) { + this.responseHandler = responseHandler; + return this; + } + + /** + * Sets the responses provider for state persistence (optional). + *

+ * If not set, a provider is resolved automatically based on the hosting + * environment (Foundry storage in hosted environments, in-memory locally). + * + * @param provider the responses' provider. + * @return this builder. + */ + public Builder provider(ResponsesProvider provider) { + this.provider = provider; + return this; + } + + /** + * Builds and returns a new {@link ResponsesApi} instance. + * + * @return the constructed {@link ResponsesApi}. + * @throws NullPointerException if {@code responseHandler} is null. + */ + public ResponsesApi build() { + Objects.requireNonNull(responseHandler, "responseHandler must not be null"); + if (provider == null) { + provider = resolveProvider(); + } + return new AgentServerResponsesApi(responseHandler, provider); + } + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponsesProvider.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponsesProvider.java new file mode 100644 index 000000000000..b4a7dd1e85d4 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponsesProvider.java @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.microsoft.agentserver.api.implementation.InMemoryResponseProvider; +import com.openai.models.responses.ResponseItem; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +/** + * Provides pluggable response state persistence for the Responses API server. + *

+ * Items are stored as {@link ResponseItem}, a union type that naturally carries + * role information: {@code ResponseInputMessageItem} (user/system/developer) vs + * {@code ResponseOutputMessage} (assistant), plus tool calls and other item types. + *

+ * When no custom implementation is registered, the SDK provides an in-memory default. + */ +public interface ResponsesProvider { + + static ResponsesProvider inMemory() { + return new InMemoryResponseProvider(); + } + + /** + * Retrieves items by their identifiers (batch lookup). + * Items not found are returned as {@code null} entries. + * + * @param itemIds the item identifiers to look up. + * @return a future containing items matching the requested IDs + * ({@code null} for missing entries). + */ + CompletableFuture> getItemsAsync(List itemIds); + + /** + * Retrieves the list of history item IDs for a conversation chain, + * starting from a previous response and/or conversation ID. + * + * @param previousResponseId the previous response ID to look up history from, or {@code null}. + * @param conversationId the conversation ID to scope history, or {@code null}. + * @param limit maximum number of history item IDs to return. + * @return a future containing an ordered list of history item IDs. + */ + CompletableFuture> getHistoryItemIdsAsync( + String previousResponseId, + String conversationId, + int limit); + + /** + * Persists a completed response and its output items. + * + * @param responseId the response ID. + * @param response the full response object. + * @param previousResponseId the previous response ID, or {@code null}. + * @param conversationId the conversation ID, or {@code null}. + * @return a future that completes when persistence is done. + */ + CompletableFuture saveResponseAsync( + String responseId, + com.openai.models.responses.Response response, + String previousResponseId, + String conversationId); + + /** + * Persists the resolved input items for a response, so they appear + * in conversation history before the output items. + * + * @param responseId the response ID these input items belong to. + * @param inputItems the resolved input items as {@link ResponseItem}s. + * @return a future that completes when persistence is done. + */ + default CompletableFuture saveInputItemsAsync( + String responseId, + List inputItems) { + return CompletableFuture.completedFuture(null); + } + + /** + * Creates a response in storage with its input items and history in a single + * atomic operation (envelope format). This matches the Foundry storage API + * contract used by the C# and Python SDKs. + *

+ * The default implementation delegates to {@link #saveResponseAsync} for + * backward compatibility with in-memory providers. + * + * @param responseId the response ID. + * @param response the full response object. + * @param inputItems the resolved input items. + * @param historyItemIds the history item IDs from prior conversation turns. + * @return a future that completes when persistence is done. + */ + default CompletableFuture createResponseAsync( + String responseId, + com.openai.models.responses.Response response, + List inputItems, + List historyItemIds) { + // Default: delegate to the legacy separate-call pattern + return saveResponseAsync(responseId, response, null, null); + } + + /** + * Retrieves the stored input items for a given response. + * These are the items that were sent as input when the response was created. + * + * @param responseId the response ID. + * @return a future containing the input items, or an empty list if not found. + */ + default CompletableFuture> getInputItemsForResponseAsync(String responseId) { + return CompletableFuture.completedFuture(List.of()); + } + + /** + * Retrieves a previously stored response by its ID. + * + * @param responseId the response ID. + * @return a future containing the response, or empty if not found. + */ + CompletableFuture> getResponseAsync(String responseId); + + /** + * Retrieves a previously stored response by its ID, forwarding the inbound + * request's platform isolation headers to the backend (required by Foundry + * storage, which partitions responses by isolation key — a GET without the + * matching headers returns 404 even though the response exists). + *

+ * Default implementation ignores isolation for in-memory providers. + * + * @param responseId the response ID. + * @param isolation the inbound isolation context (may be {@code null} for tests / local runs). + * @return a future containing the response, or empty if not found. + */ + default CompletableFuture> getResponseAsync( + String responseId, IsolationContext isolation) { + return getResponseAsync(responseId); + } + + /** + * Deletes a previously stored response by its ID. + * + * @param responseId the response ID. + * @return a future that completes when deletion is done. + */ + CompletableFuture deleteResponseAsync(String responseId); +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/SessionIdResolver.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/SessionIdResolver.java new file mode 100644 index 000000000000..03ed5da92b3d --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/SessionIdResolver.java @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.microsoft.agentserver.api.implementation.IdGenerator; +import com.openai.core.JsonValue; +import com.openai.models.responses.ResponseCreateParams; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Locale; +import java.util.Optional; + +/** + * Resolves the {@code agent_session_id} for a response, per the Responses + * the protocol spec and. + *

+ * Priority chain: + *

    + *
  1. {@code request.agent_session_id} — payload-level client-supplied affinity.
  2. + *
  3. {@code FOUNDRY_AGENT_SESSION_ID} environment variable — platform-supplied.
  4. + *
  5. Deterministic derivation — SHA-256 of {@code "agentName:agentVersion:partitionHint"}, + * first 63 lowercase hex characters. Falls back to a random 63-char lowercase + * hex when no conversational context (no {@code conversation_id} / + * {@code previous_response_id}) is present.
  6. + *
+ * The resolved value is always 63 lowercase hex characters (or whatever the + * client supplied at tier 1 — that string is returned unchanged). + */ +final class SessionIdResolver { + + /** + * Output length of the deterministic derivation. + */ + private static final int HEX_LENGTH = 63; + + /** + * Default agent name when {@code agent_reference.name} is absent or empty. + */ + private static final String DEFAULT_AGENT_NAME = "server-default-agent"; + + private static final SecureRandom RANDOM = new SecureRandom(); + + private SessionIdResolver() { + } + + /** + * Resolves the session ID for the given request. + * + * @param request the create-response request (non-null) + * @param envSessionId the value of {@code FOUNDRY_AGENT_SESSION_ID}, or {@code null}/empty + * @return the resolved session ID (never null/empty) + */ + static String resolve(AgentServerCreateResponse request, String envSessionId) { + // Tier 1: client-supplied payload field. + String fromPayload = extractPayloadSessionId(request); + if (isNonEmpty(fromPayload)) { + return fromPayload; + } + // Tier 2: platform-supplied environment variable. + if (isNonEmpty(envSessionId)) { + return envSessionId; + } + // Tier 3: deterministic derivation (or random if no context). + return derive(request); + } + + /** + * Returns the {@code agent_session_id} value from the request body's top-level + * additional properties, or {@code null} when absent / wrong type / empty. + */ + private static String extractPayloadSessionId(AgentServerCreateResponse request) { + try { + ResponseCreateParams.Body body = request.responseCreateParams(); + if (body == null) { + return null; + } + JsonValue val = body._additionalProperties().get("agent_session_id"); + if (val == null) { + return null; + } + @SuppressWarnings("unchecked") + Optional str = val.asString(); + return str.filter(SessionIdResolver::isNonEmpty).orElse(null); + } catch (Exception e) { + return null; + } + } + + /** + * Computes the deterministic session ID per Tier 3 of the algorithm. + */ + private static String derive(AgentServerCreateResponse request) { + ResponseCreateParams.Body body = request.responseCreateParams(); + + // Select partition source: conversation_id wins, then previous_response_id, + // else random. + String partitionSource = null; + if (body != null) { + Optional convId = body.conversation().flatMap(c -> { + if (c.isId()) { + return Optional.of(c.asId()); + } + if (c.isResponseConversationParam()) { + return Optional.of(c.asResponseConversationParam().id()); + } + return Optional.empty(); + }).filter(SessionIdResolver::isNonEmpty); + + if (convId.isPresent()) { + partitionSource = convId.get(); + } else { + Optional prev = body.previousResponseId().filter(SessionIdResolver::isNonEmpty); + if (prev.isPresent()) { + partitionSource = prev.get(); + } + } + } + + if (partitionSource == null) { + return randomHex(); + } + + // Extract the 18-char partition key if the source is a well-formed Foundry ID; + // otherwise use the raw value as the hint. + String partitionHint; + try { + partitionHint = IdGenerator.extractPartitionKey(partitionSource); + } catch (RuntimeException ex) { + partitionHint = partitionSource; + } + + // Agent identity. + String agentName = DEFAULT_AGENT_NAME; + String agentVersion = ""; + AgentReference ref = request.agent(); + if (ref != null) { + if (isNonEmpty(ref.name())) { + agentName = ref.name(); + } + if (ref.version() != null) { + agentVersion = ref.version(); + } + } + + String seed = agentName + ":" + agentVersion + ":" + partitionHint; + return sha256Hex(seed).substring(0, HEX_LENGTH); + } + + private static String randomHex() { + // 63 chars → 32 bytes (we'll truncate the trailing nibble). + byte[] bytes = new byte[(HEX_LENGTH + 1) / 2]; + RANDOM.nextBytes(bytes); + return toHex(bytes).substring(0, HEX_LENGTH); + } + + private static String sha256Hex(String input) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(input.getBytes(StandardCharsets.UTF_8)); + return toHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + private static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + // Already lowercase via Character.forDigit. + return sb.toString().toLowerCase(Locale.ROOT); + } + + private static boolean isNonEmpty(String s) { + return s != null && !s.isEmpty(); + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/TrustStoreInstaller.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/TrustStoreInstaller.java new file mode 100644 index 000000000000..9e706981ccdf --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/TrustStoreInstaller.java @@ -0,0 +1,635 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.agentserver.api; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509ExtendedTrustManager; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.cert.CertPathBuilder; +import java.security.cert.CertStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.CollectionCertStoreParameters; +import java.security.cert.PKIXBuilderParameters; +import java.security.cert.PKIXCertPathBuilderResult; +import java.security.cert.TrustAnchor; +import java.security.cert.X509CertSelector; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Utility for installing additional trusted CA certificates into the JVM at runtime without + * mutating the JDK's {@code cacerts} file or the OS certificate bundle. + * + *

Two layers are available, configurable independently per call: + *

    + *
  1. Merged keystore install. The JDK default {@code cacerts} is loaded + * into memory, the supplied certificate(s) are added, the merged keystore is written + * to a temporary file, and the JVM system properties {@code javax.net.ssl.trustStore} + * / {@code javax.net.ssl.trustStorePassword} / {@code javax.net.ssl.trustStoreType} + * are pointed at it. HTTP clients that consult these properties (notably reactor-netty + * inside {@code azure-core-http-netty}) will then trust the added cert globally.
  2. + *
  3. Host-scoped JVM-default {@link SSLContext}. When a host predicate + * is supplied, a composite trust manager is built that consults the added cert only + * for hosts matched by the predicate (the JDK default trust set applies to every other + * host) and is registered via {@link SSLContext#setDefault(SSLContext)}. Code paths + * using the JVM-default {@code SSLContext} pick it up automatically; reactor-netty's + * default {@code HttpClient} does not — see the caveat on + * {@link #installAdcEgressProxyCertificate(Logger, Predicate)}.
  4. + *
+ * + *

This must be invoked before any code triggers initialization of the + * default {@link SSLContext} (i.e. before the first outbound HTTPS call), otherwise the + * change will not take effect for the current JVM. + * + *

For every certificate added, the installer logs an audit line capturing subject DN, + * issuer DN, validity window, serial number, SHA-256 fingerprint, and a PKIX chain summary. + * + *

Primary use case — ADC egress proxy CA

+ * In the Azure AI Foundry vNext ("ADC") hosting environment, traffic to the Foundry project + * endpoint (matching {@link #defaultAdcProxiedHosts()}) is TLS-terminated by an egress proxy + * whose CA cert is mounted at {@link #ADC_EGRESS_PROXY_CA_CERT}. The recommended bootstrap + * is a single call from {@code main()}: + *
{@code
+ *   TrustStoreInstaller.installAdcEgressProxyCertificate();
+ * }
+ * This performs both layers above against the mounted proxy CA, with host-scoping defaulted + * to {@link #defaultAdcProxiedHosts()}. Use {@link #installAdcEgressProxyCertificate(Logger)} + * to route the audit log through your application logger, or + * {@link #installAdcEgressProxyCertificate(Logger, Predicate)} to override the host-scoping + * predicate. To install an arbitrary (non-ADC) proxy CA, use + * {@link #installProxyCertificate(Logger, Predicate, Path, String)}. + */ +public final class TrustStoreInstaller { + + private static final Logger LOGGER = LoggerFactory.getLogger(TrustStoreInstaller.class); + private static final String DEFAULT_CACERTS_PASSWORD = "changeit"; + + /** + * Well-known path at which the Azure Agent Server (vNext "ADC" hosting environment) mounts + * the egress proxy CA certificate required for outbound HTTPS calls to Foundry endpoints. + * Absent when running locally. + */ + public static final Path ADC_EGRESS_PROXY_CA_CERT = + Paths.get("/etc/ssl/certs/adc-egress-proxy-ca.crt"); + + private TrustStoreInstaller() { + } + + /** + * Returns the caller-supplied logger when non-null, otherwise the class-static logger. + * Used so that callers may opt-in to routing lifecycle messages through their own + * application-configured SLF4J logger while still getting sensible defaults when they + * pass {@code null}. + * + * @param logger optional caller-supplied logger; may be {@code null}. + * @return {@code logger} when non-null, otherwise the class-static logger. + */ + private static Logger logger(Logger logger) { + return logger != null ? logger : LOGGER; + } + + /** + * Installs the Azure Agent Server (ADC) egress proxy CA certificate from the well-known + * mount path {@code /etc/ssl/certs/adc-egress-proxy-ca.crt}, applying both layers documented + * on the class JavaDoc: the merged-keystore install and a host-scoped JVM-default + * {@link SSLContext} scoped to {@link #defaultAdcProxiedHosts()}. This is the recommended + * one-line bootstrap from {@code main()}. Uses the class-static SLF4J logger for lifecycle + * messages — call {@link #installAdcEgressProxyCertificate(Logger)} to route them through + * your own application logger instead. + * + *

No-op when the certificate file is not present (e.g. when running locally outside + * the ADC hosting environment), so it is safe to call unconditionally during startup. + * + * @return {@code true} if the certificate was found and installed, {@code false} otherwise. + * @throws IOException if reading the cert or default cacerts fails. + * @throws GeneralSecurityException if the keystore cannot be loaded or the certificate parsed. + */ + public static boolean installAdcEgressProxyCertificate() + throws IOException, GeneralSecurityException { + return installAdcEgressProxyCertificate(null, defaultAdcProxiedHosts()); + } + + /** + * Variant of {@link #installAdcEgressProxyCertificate()} that routes lifecycle messages + * through the caller's SLF4J logger. Applies both layers (merged-keystore install plus + * host-scoping to {@link #defaultAdcProxiedHosts()}). Pass {@code null} for {@code logger} + * to fall back to the class-static logger. + */ + public static boolean installAdcEgressProxyCertificate(Logger logger) + throws IOException, GeneralSecurityException { + return installAdcEgressProxyCertificate(logger, defaultAdcProxiedHosts()); + } + + /** + * Variant that lets the caller override the host-scoping predicate (or disable + * host-scoping entirely by passing {@code null}). Equivalent to the no-arg / one-arg + * overloads when {@code adcHosts == defaultAdcProxiedHosts()}. + * + *

Caveat. Some HTTP clients (notably reactor-netty's default + * {@code HttpClient}, used by {@code azure-core-http-netty}) build their own + * {@code SslContext} from the {@code javax.net.ssl.trustStore} system property and do + * not consult {@link SSLContext#getDefault()}. For those clients the + * merged-keystore install still makes the proxy CA trusted globally, but the + * host-scoping enforcement does not apply. + * + * @param logger optional SLF4J logger; {@code null} uses the class-static logger. + * @param adcHosts predicate evaluated against {@code SSLEngine.getPeerHost()} / + * {@code Socket.getRemoteSocketAddress()}; only matching hosts will see + * the proxy CA in their trust set. Pass {@code null} to disable + * host-scoping entirely (merged-keystore install only). + */ + public static boolean installAdcEgressProxyCertificate(Logger logger, Predicate adcHosts) + throws IOException, GeneralSecurityException { + return installProxyCertificate(logger, adcHosts, ADC_EGRESS_PROXY_CA_CERT, "adc-egress-proxy"); + } + + /** + * Generic variant for installing any proxy CA certificate file with optional host-scoping. + * Equivalent to {@link #installAdcEgressProxyCertificate(Logger, Predicate)} but + * parameterised by certificate path and alias prefix instead of using the ADC defaults. + * + *

Performs the two layers documented on the class JavaDoc — the merged-keystore + * install always; the host-scoped {@link SSLContext} only when {@code adcHosts} is + * non-{@code null}. + * + *

No-op if {@code certPath} does not exist (returns {@code false}), making it safe to + * call unconditionally during application startup. + * + * @param logger optional SLF4J logger; {@code null} uses the class-static logger. + * @param adcHosts optional host predicate; {@code null} disables host-scoping. The + * parameter name reflects the primary ADC use case, but the predicate + * may match any host pattern relevant to the supplied cert. + * @param certPath path to a PEM- or DER-encoded X.509 certificate file (may contain + * multiple PEM-encoded certificates concatenated together). + * @param aliasPrefix prefix used to derive unique aliases ({@code aliasPrefix-N}) for the + * imported certificates in the merged keystore. + */ + public static boolean installProxyCertificate( + Logger logger, Predicate adcHosts, Path certPath, String aliasPrefix) + throws IOException, GeneralSecurityException { + boolean installed = installIntoMergedKeystore(certPath, aliasPrefix, logger); + if (installed && adcHosts != null && Files.isRegularFile(certPath)) { + installHostScopedDefaultSslContext(logger, adcHosts, certPath); + } + return installed; + } + + /** + * Merged-keystore install: parses certs from {@code certPath}, adds them to a copy of + * the JDK {@code cacerts} under {@code aliasPrefix-N}, writes the merged keystore to a + * temp file, and points the JVM at it via the {@code javax.net.ssl.trustStore} system + * properties. No-op if {@code certPath} does not exist. + */ + private static boolean installIntoMergedKeystore(Path certPath, String aliasPrefix, Logger logger) + throws IOException, GeneralSecurityException { + Objects.requireNonNull(certPath, "certPath"); + Objects.requireNonNull(aliasPrefix, "aliasPrefix"); + + Logger log = logger(logger); + + if (!Files.isRegularFile(certPath)) { + log.debug("No CA cert found at {}; skipping truststore install.", certPath); + return false; + } + + KeyStore merged = loadDefaultCacerts(logger); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Collection certs; + try (InputStream in = Files.newInputStream(certPath)) { + certs = cf.generateCertificates(in); + } + if (certs.isEmpty()) { + log.warn("No certificates were parsed from {}.", certPath); + return false; + } + + // Build the candidate pool used during chain-building so that intermediates shipped + // alongside the leaf certificate in the same file are discoverable. + List intermediates = new ArrayList<>(); + for (Certificate c : certs) { + if (c instanceof X509Certificate x) { + intermediates.add(x); + } + } + + int added = 0; + int i = 0; + for (Certificate cert : certs) { + String alias = aliasPrefix + "-" + i++; + ChainInfo chain = (cert instanceof X509Certificate x) + ? buildChain(x, intermediates, merged) + : ChainInfo.NONE; + + logCertificateDetails(log, alias, cert, chain); + + merged.setCertificateEntry(alias, cert); + added++; + } + + if (added == 0) { + log.warn("No certificates from {} were added to the truststore.", certPath); + return false; + } + + Path mergedFile = Files.createTempFile("cacerts-with-" + aliasPrefix + "-", ".jks"); + mergedFile.toFile().deleteOnExit(); + try (OutputStream out = Files.newOutputStream(mergedFile)) { + merged.store(out, DEFAULT_CACERTS_PASSWORD.toCharArray()); + } + + System.setProperty("javax.net.ssl.trustStore", mergedFile.toAbsolutePath().toString()); + System.setProperty("javax.net.ssl.trustStorePassword", DEFAULT_CACERTS_PASSWORD); + System.setProperty("javax.net.ssl.trustStoreType", merged.getType()); + + log.info("Installed {} CA certificate(s) from {} into merged truststore {}.", + added, certPath, mergedFile); + return true; + } + + private static KeyStore loadDefaultCacerts(Logger logger) throws IOException, GeneralSecurityException { + Logger log = logger(logger); + Path cacerts = Paths.get(System.getProperty("java.home"), "lib", "security", "cacerts"); + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + if (Files.isRegularFile(cacerts)) { + try (InputStream in = Files.newInputStream(cacerts)) { + ks.load(in, DEFAULT_CACERTS_PASSWORD.toCharArray()); + } + } else { + log.warn("Default JDK cacerts not found at {}; starting from an empty truststore.", cacerts); + ks.load(null, DEFAULT_CACERTS_PASSWORD.toCharArray()); + } + return ks; + } + + /** + * Emits an INFO line describing the certificate being trusted: alias, subject DN, + * issuer DN, validity window, serial number, SHA-256 fingerprint, and trust chain + * (root subject + fingerprint, whether the chain validates against the JDK cacerts). + */ + private static void logCertificateDetails(Logger log, String alias, Certificate cert, ChainInfo chain) { + if (cert instanceof X509Certificate x509) { + log.info( + "Adding CA certificate [alias={}] subject=\"{}\" issuer=\"{}\" " + + "notBefore={} notAfter={} serial={} sha256={} chain={}", + alias, + x509.getSubjectX500Principal().getName(), + x509.getIssuerX500Principal().getName(), + x509.getNotBefore().toInstant(), + x509.getNotAfter().toInstant(), + x509.getSerialNumber().toString(16), + sha256Fingerprint(x509), + chain.summary()); + } else { + log.info("Adding CA certificate [alias={}] type={}", alias, cert.getType()); + } + } + + private static String sha256Fingerprint(X509Certificate cert) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(cert.getEncoded()); + return HexFormat.of().withUpperCase().withDelimiter(":").formatHex(digest); + } catch (GeneralSecurityException e) { + return ""; + } + } + + /** + * Attempts to build a PKIX trust chain from {@code target} to a trust anchor in + * {@code trustAnchors} (typically the JDK's cacerts), using {@code intermediates} as a + * candidate pool. Returns a {@link ChainInfo} that captures the outcome — whether the + * chain is trusted, the root certificate (if any), and a short human-readable summary. + * + *

Self-signed certificates are handled as a special case: the cert is treated as its + * own root and reported as "self-signed (not anchored)" unless an equal cert already + * exists in {@code trustAnchors}, in which case it is reported as "self-signed (anchored)". + */ + private static ChainInfo buildChain(X509Certificate target, + Collection intermediates, + KeyStore trustAnchors) { + boolean selfSigned = target.getSubjectX500Principal().equals(target.getIssuerX500Principal()); + if (selfSigned) { + boolean anchored = trustAnchorsContain(trustAnchors, target); + String summary = "self-signed root sha256=" + sha256Fingerprint(target) + + (anchored ? " (anchored in cacerts)" : " (not anchored in cacerts)"); + return new ChainInfo(anchored, target, summary); + } + + try { + Set anchors = new HashSet<>(); + Enumeration aliases = trustAnchors.aliases(); + while (aliases.hasMoreElements()) { + String a = aliases.nextElement(); + if (trustAnchors.isCertificateEntry(a)) { + Certificate c = trustAnchors.getCertificate(a); + if (c instanceof X509Certificate x) { + anchors.add(new TrustAnchor(x, null)); + } + } + } + if (anchors.isEmpty()) { + return new ChainInfo(false, null, "no trust anchors available"); + } + + X509CertSelector sel = new X509CertSelector(); + sel.setCertificate(target); + + PKIXBuilderParameters params = new PKIXBuilderParameters(anchors, sel); + params.setRevocationEnabled(false); + params.addCertStore(CertStore.getInstance("Collection", + new CollectionCertStoreParameters(intermediates))); + + CertPathBuilder builder = CertPathBuilder.getInstance("PKIX"); + PKIXCertPathBuilderResult result = (PKIXCertPathBuilderResult) builder.build(params); + X509Certificate root = result.getTrustAnchor().getTrustedCert(); + String summary = "trusted via PKIX: root subject=\"" + + root.getSubjectX500Principal().getName() + + "\" root sha256=" + sha256Fingerprint(root); + return new ChainInfo(true, root, summary); + } catch (GeneralSecurityException e) { + return new ChainInfo(false, null, "no trusted chain (" + e.getMessage() + ")"); + } + } + + private static boolean trustAnchorsContain(KeyStore trustAnchors, X509Certificate cert) { + try { + Enumeration aliases = trustAnchors.aliases(); + while (aliases.hasMoreElements()) { + String a = aliases.nextElement(); + if (trustAnchors.isCertificateEntry(a) && cert.equals(trustAnchors.getCertificate(a))) { + return true; + } + } + } catch (GeneralSecurityException ignored) { + // fall through + } + return false; + } + + /** + * Result of a single chain-building attempt against the JDK cacerts. + */ + private record ChainInfo(boolean trusted, X509Certificate root, String summary) { + static final ChainInfo NONE = new ChainInfo(false, null, "n/a"); + } + + // ────────────────────────────────────────────────────────────────── + // Host-scoped trust manager + // ────────────────────────────────────────────────────────────────── + + /** + * Default predicate matching the host pattern empirically observed to be MITM'd by the + * ADC egress proxy in Azure AI Foundry vNext: *.services.ai.azure.com (the + * unified Foundry project / storage / OpenAI endpoint pattern). + */ + public static Predicate defaultAdcProxiedHosts() { + return host -> { + if (host == null) return false; + String h = host.toLowerCase(Locale.ROOT); + return h.endsWith(".services.ai.azure.com"); + }; + } + + /** + * Builds a host-scoped trust manager set: certificates loaded from {@code certPath} are + * trusted only when validating server certificates for hosts matched by {@code adcHosts}. + * For all other hosts — and as a fall-through for matched hosts whose server cert was not + * signed by the scoped CA — the JDK default trust set is used. + * + * @param certPath path to PEM/DER cert file to install under scoped trust. + * @param adcHosts host predicate; only matching hosts will see the scoped CA. + * @param logger optional SLF4J logger; {@code null} uses the class-static logger. + */ + private static TrustManager[] hostScopedTrustManagers( + Path certPath, Predicate adcHosts, Logger logger) + throws IOException, GeneralSecurityException { + + Objects.requireNonNull(certPath, "certPath"); + Objects.requireNonNull(adcHosts, "adcHosts"); + Logger log = logger(logger); + + TrustManagerFactory defaultTmf = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + defaultTmf.init((KeyStore) null); + X509ExtendedTrustManager defaultTm = firstX509Extended(defaultTmf, + "JDK default trust manager"); + + KeyStore scopedOnly = KeyStore.getInstance(KeyStore.getDefaultType()); + scopedOnly.load(null, null); + + int added = 0; + if (Files.isRegularFile(certPath)) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Collection certs; + try (InputStream in = Files.newInputStream(certPath)) { + certs = cf.generateCertificates(in); + } + List intermediates = new ArrayList<>(); + for (Certificate c : certs) { + if (c instanceof X509Certificate x) { + intermediates.add(x); + } + } + KeyStore cacertsForChainBuild = loadDefaultCacerts(log); + int i = 0; + for (Certificate cert : certs) { + String alias = "host-scoped-" + i++; + if (!(cert instanceof X509Certificate x)) continue; + buildChain(x, intermediates, cacertsForChainBuild); // populates chain context for log + scopedOnly.setCertificateEntry(alias, x); + added++; + } + } else { + log.debug("Host-scoped trust manager: cert file {} not present; scoped trust set is empty " + + "and will always fall through to JDK default.", certPath); + } + + TrustManagerFactory scopedTmf = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + scopedTmf.init(scopedOnly); + X509ExtendedTrustManager scopedTm = firstX509Extended(scopedTmf, + "Scoped trust manager"); + + log.info("Host-scoped trust manager built: {} cert(s) trusted for predicate-matched hosts; " + + "JDK default trust applies to all other hosts.", added); + + return new TrustManager[]{ + new HostScopedTrustManager(scopedTm, defaultTm, adcHosts) + }; + } + + /** + * Builds a host-scoped {@link SSLContext} via {@link #hostScopedTrustManagers} and registers + * it as the JVM default via {@link SSLContext#setDefault(SSLContext)}. See the caveat on + * {@link #installAdcEgressProxyCertificate(Logger, Predicate)} about HTTP clients that + * bypass the JVM-default {@link SSLContext}. + */ + private static void installHostScopedDefaultSslContext( + Logger logger, Predicate adcHosts, Path certPath) + throws IOException, GeneralSecurityException { + Logger log = logger(logger); + TrustManager[] tms = hostScopedTrustManagers(certPath, adcHosts, log); + SSLContext ctx; + try { + ctx = SSLContext.getInstance("TLS"); + ctx.init(null, tms, null); + } catch (GeneralSecurityException e) { + log.warn("Failed to construct host-scoped SSLContext; default SSLContext unchanged.", e); + return; + } + SSLContext.setDefault(ctx); + log.info("Installed host-scoped SSLContext as the JVM default; scoped CA is trusted " + + "only for hosts matching the supplied predicate."); + } + + private static X509ExtendedTrustManager firstX509Extended(TrustManagerFactory tmf, String label) { + for (TrustManager tm : tmf.getTrustManagers()) { + if (tm instanceof X509ExtendedTrustManager x) { + return x; + } + } + throw new IllegalStateException(label + " did not provide an X509ExtendedTrustManager"); + } + + /** + * Composite {@link X509ExtendedTrustManager} that consults a scoped trust manager + * (containing only the explicitly-added CA(s)) for hosts matched by a predicate and the + * JDK default trust manager for everything else. + * + *

Behaviour: + *

    + *
  • For matched hosts: the scoped trust manager validates first; on failure the JDK + * default is consulted as a fall-back (so direct connections to the real service + * endpoint, not through the proxy, still work when the platform-mounted proxy CA + * is absent).
  • + *
  • For unmatched hosts: only the JDK default applies — the scoped CA is + * never consulted, so a compromised proxy CA cannot impersonate + * public-internet hosts.
  • + *
  • For the legacy {@code checkServerTrusted(chain, authType)} overload (no peer + * host available): only the JDK default applies (fail-safe).
  • + *
+ */ + private static final class HostScopedTrustManager extends X509ExtendedTrustManager { + + private final X509ExtendedTrustManager scopedTm; + private final X509ExtendedTrustManager defaultTm; + private final Predicate scopedHosts; + + HostScopedTrustManager(X509ExtendedTrustManager scopedTm, + X509ExtendedTrustManager defaultTm, + Predicate scopedHosts) { + this.scopedTm = Objects.requireNonNull(scopedTm, "scopedTm"); + this.defaultTm = Objects.requireNonNull(defaultTm, "defaultTm"); + this.scopedHosts = Objects.requireNonNull(scopedHosts, "scopedHosts"); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + X509Certificate[] scoped = scopedTm.getAcceptedIssuers(); + X509Certificate[] def = defaultTm.getAcceptedIssuers(); + X509Certificate[] all = new X509Certificate[scoped.length + def.length]; + System.arraycopy(scoped, 0, all, 0, scoped.length); + System.arraycopy(def, 0, all, scoped.length, def.length); + return all; + } + + // ── client trust (rare; defer to default) ───────────────────── + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + defaultTm.checkClientTrusted(chain, authType); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) throws CertificateException { + defaultTm.checkClientTrusted(chain, authType, socket); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) throws CertificateException { + defaultTm.checkClientTrusted(chain, authType, engine); + } + + // ── server trust (the host-scoping decision lives here) ─────── + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + // No peer-host information on this overload — fail-safe to JDK default only. + defaultTm.checkServerTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) throws CertificateException { + checkServerTrustedForPeer(chain, authType, peerHost(socket), socket, null); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) throws CertificateException { + String peer = engine == null ? null : engine.getPeerHost(); + checkServerTrustedForPeer(chain, authType, peer, null, engine); + } + + private void checkServerTrustedForPeer(X509Certificate[] chain, String authType, + String peer, Socket socket, SSLEngine engine) + throws CertificateException { + + if (peer != null && scopedHosts.test(peer)) { + try { + if (socket != null) { + scopedTm.checkServerTrusted(chain, authType, socket); + } else if (engine != null) { + scopedTm.checkServerTrusted(chain, authType, engine); + } else { + scopedTm.checkServerTrusted(chain, authType); + } + return; + } catch (CertificateException ignored) { + // fall through to default + } + } + + if (socket != null) { + defaultTm.checkServerTrusted(chain, authType, socket); + } else if (engine != null) { + defaultTm.checkServerTrusted(chain, authType, engine); + } else { + defaultTm.checkServerTrusted(chain, authType); + } + } + + private static String peerHost(Socket socket) { + if (socket == null) return null; + SocketAddress addr = socket.getRemoteSocketAddress(); + if (addr instanceof InetSocketAddress isa) { + return isa.getHostString(); + } + return null; + } + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/FoundryStorageException.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/FoundryStorageException.java new file mode 100644 index 000000000000..ebb09c429f37 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/FoundryStorageException.java @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.implementation; + +/** + * Exception thrown when a Foundry storage API operation fails. + *

+ * Contains the HTTP status code and response body for diagnostic purposes. + */ +public final class FoundryStorageException extends RuntimeException { + + private final int statusCode; + private final String responseBody; + + /** + * Creates a new {@code FoundryStorageException}. + * + * @param message the exception message + * @param statusCode the HTTP status code returned by the storage API + * @param responseBody the response body, or empty string if unavailable + */ + public FoundryStorageException(String message, int statusCode, String responseBody) { + super(message); + this.statusCode = statusCode; + this.responseBody = responseBody != null ? responseBody : ""; + } + + /** + * Creates a new {@code FoundryStorageException} with a cause. + * + * @param message the exception message + * @param cause the underlying cause + */ + public FoundryStorageException(String message, Throwable cause) { + super(message, cause); + this.statusCode = 0; + this.responseBody = ""; + } + + /** + * Returns the HTTP status code from the failed storage API call. + * + * @return the status code, or 0 if not applicable + */ + public int getStatusCode() { + return statusCode; + } + + /** + * Returns the response body from the failed storage API call. + * + * @return the response body, or an empty string + */ + public String getResponseBody() { + return responseBody; + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/FoundryStorageProvider.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/FoundryStorageProvider.java new file mode 100644 index 000000000000..36a1872cc128 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/FoundryStorageProvider.java @@ -0,0 +1,656 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.implementation; + +import com.azure.core.credential.TokenCredential; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.HttpPipelineBuilder; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.policy.BearerTokenAuthenticationPolicy; +import com.azure.core.http.policy.HttpLogOptions; +import com.azure.core.http.policy.HttpLoggingPolicy; +import com.azure.core.http.policy.RetryPolicy; +import com.azure.core.util.BinaryData; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.microsoft.agentserver.api.IsolationContext; +import com.microsoft.agentserver.api.PlatformHeaders; +import com.microsoft.agentserver.api.ResponsesProvider; +import com.microsoft.agentserver.api.serialization.ObjectMapperFactory; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseItem; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * HTTP-backed implementation of {@link ResponsesProvider} that persists + * state to the Azure AI Foundry storage API using an Azure Core + * {@link HttpPipeline} for retry, authentication, telemetry, and tracing. + *

+ * Equivalent to the C# {@code FoundryStorageProvider}. + */ +public final class FoundryStorageProvider implements ResponsesProvider, AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(FoundryStorageProvider.class); + private static final String API_VERSION = "v1"; + private static final String JSON_CONTENT_TYPE = "application/json; charset=utf-8"; + private static final String FOUNDRY_STORAGE_SCOPE = "https://ai.azure.com/.default"; + private static final ObjectMapper MAPPER = ObjectMapperFactory.getObjectMapper(); + + private final HttpPipeline pipeline; + private final URI storageBaseUri; + private final ExecutorService ioExecutor; + + /** + * Creates a new FoundryStorageProvider with a pre-built HttpPipeline and a custom executor + * for async I/O operations. + * + * @param pipeline the Azure Core HTTP pipeline (includes retry, auth, telemetry) + * @param storageBaseUri the base URI for the Foundry storage API (e.g. "...") + * @param ioExecutor the executor for async I/O operations + */ + public FoundryStorageProvider(HttpPipeline pipeline, URI storageBaseUri, ExecutorService ioExecutor) { + this.pipeline = pipeline; + this.storageBaseUri = storageBaseUri.toString().endsWith("/") + ? storageBaseUri + : URI.create(storageBaseUri + "/"); + this.ioExecutor = ioExecutor; + LOGGER.debug("Initialized FoundryStorageProvider with base URI: {}", this.storageBaseUri); + } + + /** + * Creates a new FoundryStorageProvider with a pre-built HttpPipeline and the + * {@linkplain #defaultIoExecutor() default I/O executor}. + * + * @param pipeline the Azure Core HTTP pipeline (includes retry, auth, telemetry) + * @param storageBaseUri the base URI for the Foundry storage API (e.g. "https://host/storage/") + */ + public FoundryStorageProvider(HttpPipeline pipeline, URI storageBaseUri) { + this(pipeline, storageBaseUri, defaultIoExecutor()); + } + + /** + * Creates a new FoundryStorageProvider with DefaultAzureCredential and the + * {@linkplain #defaultIoExecutor() default I/O executor}. + * + * @param storageBaseUri the base URI for the Foundry storage API + */ + public FoundryStorageProvider(URI storageBaseUri) { + this(storageBaseUri, new DefaultAzureCredentialBuilder().build()); + } + + /** + * Creates a new FoundryStorageProvider with a custom TokenCredential and the + * {@linkplain #defaultIoExecutor() default I/O executor}. + * + * @param storageBaseUri the base URI for the Foundry storage API + * @param credential the Azure token credential for authentication + */ + public FoundryStorageProvider(URI storageBaseUri, TokenCredential credential) { + this(buildPipeline(credential), storageBaseUri); + } + + /** + * Creates a default {@link ExecutorService} for blocking HTTP I/O operations. + *

+ * Uses a cached thread pool with daemon threads to prevent thread starvation + * of the {@code ForkJoinPool.commonPool()}, which is designed for CPU-bound work. + * + * @return a new cached thread pool with daemon threads named {@code "foundry-storage-io"} + */ + private static ExecutorService defaultIoExecutor() { + return Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "foundry-storage-io"); + t.setDaemon(true); + return t; + }); + } + + private static HttpPipeline buildPipeline(TokenCredential credential) { + return new HttpPipelineBuilder() + .policies( + new BearerTokenAuthenticationPolicy(credential, FOUNDRY_STORAGE_SCOPE), + new RetryPolicy(), + new HttpLoggingPolicy(new HttpLogOptions())) + .build(); + } + + // ── Request building ────────────────────────────────────── + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + /** + * Creates a FoundryStorageProvider from environment variables. + * Requires FOUNDRY_PROJECT_ENDPOINT to be set. + * Uses DefaultAzureCredential for authentication (managed identity in hosted environments). + * + * @return a configured FoundryStorageProvider + * @throws IllegalStateException if FOUNDRY_PROJECT_ENDPOINT is not set + */ + public static FoundryStorageProvider fromEnvironment() { + String endpoint = System.getenv("FOUNDRY_PROJECT_ENDPOINT"); + if (endpoint == null || endpoint.isBlank()) { + LOGGER.error("FOUNDRY_PROJECT_ENDPOINT environment variable is not set"); + throw new IllegalStateException( + "FOUNDRY_PROJECT_ENDPOINT environment variable is required for FoundryStorageProvider."); + } + + String baseUri = endpoint.endsWith("/") ? endpoint + "storage/" : endpoint + "/storage/"; + LOGGER.debug("Creating FoundryStorageProvider from environment with base URI: {}", baseUri); + return new FoundryStorageProvider(URI.create(baseUri)); + } + + private String buildUrl(String path, String extraQuery) { + String base = storageBaseUri.toString() + path + "?api-version=" + API_VERSION; + if (extraQuery != null && !extraQuery.isEmpty()) { + base += "&" + extraQuery; + } + return base; + } + + // ── ResponsesProvider implementation ────────────────────── + + private HttpResponse sendRequest(HttpMethod method, String path, String extraQuery, BinaryData body) { + return sendRequest(method, path, extraQuery, body, null); + } + + private HttpResponse sendRequest(HttpMethod method, String path, String extraQuery, BinaryData body, IsolationContext isolation) { + String url = buildUrl(path, extraQuery); + LOGGER.debug("Sending {} request to {}", method, url); + HttpRequest request = new HttpRequest(method, url); + + if (body != null) { + request.setBody(body); + request.setHeader(HttpHeaderName.CONTENT_TYPE, JSON_CONTENT_TYPE); + } + request.setHeader(HttpHeaderName.ACCEPT, "application/json"); + + // Apply platform isolation headers (required by Foundry storage API) + if (isolation != null) { + if (isolation.userIsolationKey() != null) { + request.setHeader(HttpHeaderName.fromString(PlatformHeaders.USER_ISOLATION_KEY), + isolation.userIsolationKey()); + } + if (isolation.chatIsolationKey() != null) { + request.setHeader(HttpHeaderName.fromString(PlatformHeaders.CHAT_ISOLATION_KEY), + isolation.chatIsolationKey()); + } + } + + HttpResponse response = pipeline.sendSync(request, com.azure.core.util.Context.NONE); + LOGGER.debug("{} {} returned status {}", method, path, response.getStatusCode()); + throwIfError(response); + return response; + } + + private void throwIfError(HttpResponse response) { + int status = response.getStatusCode(); + if (status >= 200 && status < 300) { + return; + } + String responseBody = response.getBodyAsBinaryData() != null + ? response.getBodyAsBinaryData().toString() : ""; + LOGGER.warn("Foundry storage API error: HTTP {} - {}", status, responseBody); + if (status == 404) { + throw new FoundryStorageException("Resource not found", status, responseBody); + } + throw new FoundryStorageException( + "Foundry storage API error: HTTP " + status, status, responseBody); + } + + @Override + public CompletableFuture> getItemsAsync(List itemIds) { + LOGGER.debug("getItemsAsync called with {} item IDs", itemIds.size()); + return CompletableFuture.supplyAsync(() -> { + try { + ObjectNode body = MAPPER.createObjectNode(); + ArrayNode idsArray = body.putArray("item_ids"); + for (String id : itemIds) { + idsArray.add(id); + } + + try (HttpResponse response = sendRequest( + HttpMethod.POST, "items/batch/retrieve", null, + BinaryData.fromString(MAPPER.writeValueAsString(body)))) { + + JsonNode root = MAPPER.readTree(response.getBodyAsBinaryData().toString()); + List result = new ArrayList<>(); + if (root.isArray()) { + for (JsonNode node : root) { + if (node.isNull()) { + result.add(null); + } else { + result.add(MAPPER.treeToValue(node, ResponseItem.class)); + } + } + } + LOGGER.debug("getItemsAsync returning {} items ({} requested)", result.size(), itemIds.size()); + return result; + } + } catch (IOException e) { + LOGGER.error("Failed to get items from Foundry storage for {} IDs", itemIds.size(), e); + throw new FoundryStorageException("Failed to get items from Foundry storage", e); + } + }, ioExecutor); + } + + @Override + public CompletableFuture> getHistoryItemIdsAsync( + String previousResponseId, + String conversationId, + int limit) { + LOGGER.debug("getHistoryItemIdsAsync called with previousResponseId={}, conversationId={}, limit={}", + previousResponseId, conversationId, limit); + return CompletableFuture.supplyAsync(() -> { + try { + StringBuilder query = new StringBuilder("limit=" + limit); + if (previousResponseId != null && !previousResponseId.isEmpty()) { + query.append("&previous_response_id=") + .append(URLEncoder.encode(previousResponseId, StandardCharsets.UTF_8)); + } + if (conversationId != null && !conversationId.isEmpty()) { + query.append("&conversation_id=") + .append(URLEncoder.encode(conversationId, StandardCharsets.UTF_8)); + } + + // Use the low-level send so we can treat 404 as "no history yet" + // (a brand-new conversation or response) without logging a warning. + String url = buildUrl("history/item_ids", query.toString()); + LOGGER.debug("Sending GET request to {}", url); + HttpRequest request = new HttpRequest(HttpMethod.GET, url); + request.setHeader(HttpHeaderName.ACCEPT, "application/json"); + try (HttpResponse response = pipeline.sendSync(request, com.azure.core.util.Context.NONE)) { + int status = response.getStatusCode(); + if (status == 404) { + LOGGER.debug("No prior history for previousResponseId={} / conversationId={} (404 from storage)", + previousResponseId, conversationId); + return List.of(); + } + throwIfError(response); + JsonNode root = MAPPER.readTree(response.getBodyAsBinaryData().toString()); + List ids = new ArrayList<>(); + if (root.isArray()) { + for (JsonNode node : root) { + ids.add(node.asText()); + } + } + LOGGER.debug("getHistoryItemIdsAsync returning {} IDs", ids.size()); + return ids; + } + } catch (IOException e) { + LOGGER.error("Failed to get history item IDs from Foundry storage", e); + throw new FoundryStorageException("Failed to get history item IDs from Foundry storage", e); + } + }, ioExecutor); + } + + @Override + public CompletableFuture saveResponseAsync( + String responseId, + Response response, + String previousResponseId, + String conversationId) { + LOGGER.debug("saveResponseAsync called for responseId={}, previousResponseId={}, conversationId={}", + responseId, previousResponseId, conversationId); + // Note: input items are included via createResponseAsync; this is now only used + // as a fallback or for update-only scenarios. + return createResponseAsync(responseId, response, List.of(), List.of()); + } + + @Override + public CompletableFuture saveInputItemsAsync( + String responseId, + List inputItems) { + // Input items are now sent as part of the create envelope in createResponseAsync. + // This method is kept for interface compatibility but is effectively a no-op + // when called after createResponseAsync has already included the items. + LOGGER.debug("saveInputItemsAsync called for responseId={} with {} items (no-op, items sent in envelope)", + responseId, inputItems != null ? inputItems.size() : 0); + return CompletableFuture.completedFuture(null); + } + + /** + * Creates a response in Foundry storage using the envelope format. + * Sends POST /responses with body: {"response": {...}, "input_items": [...], "history_item_ids": [...]} + * This matches the C# and Python SDK CreateResponseAsync contract. + * + * @param responseId the response ID + * @param response the response object + * @param inputItems the resolved input items + * @param historyItemIds the history item IDs from prior conversation turns + * @return a future that completes when persistence is done + */ + public CompletableFuture createResponseAsync( + String responseId, + Response response, + List inputItems, + List historyItemIds) { + return createResponseAsync(responseId, response, inputItems, historyItemIds, null); + } + + /** + * Creates a response in Foundry storage using the envelope format, + * forwarding platform isolation headers to the storage API. + * + * @param responseId the response ID + * @param response the response object + * @param inputItems the resolved input items + * @param historyItemIds the history item IDs from prior conversation turns + * @param isolation the isolation context from the inbound request (may be null) + * @return a future that completes when persistence is done + */ + public CompletableFuture createResponseAsync( + String responseId, + Response response, + List inputItems, + List historyItemIds, + IsolationContext isolation) { + LOGGER.debug("createResponseAsync called for responseId={} with {} input items and {} history IDs", + responseId, inputItems != null ? inputItems.size() : 0, + historyItemIds != null ? historyItemIds.size() : 0); + return CompletableFuture.runAsync(() -> { + try { + // Build the envelope: {"response": {...}, "input_items": [...], "history_item_ids": [...]} + ObjectNode envelope = MAPPER.createObjectNode(); + + // Serialize the response and sanitize it + JsonNode responseNode = MAPPER.valueToTree(response); + ObjectNode sanitized = sanitizeResponseForStorage((ObjectNode) responseNode); + envelope.set("response", sanitized); + + // Serialize input items + ArrayNode itemsArray = MAPPER.createArrayNode(); + if (inputItems != null) { + for (ResponseItem item : inputItems) { + JsonNode itemNode = MAPPER.valueToTree(item); + itemsArray.add(itemNode); + } + } + envelope.set("input_items", itemsArray); + + // Serialize history item IDs + ArrayNode historyArray = MAPPER.createArrayNode(); + if (historyItemIds != null) { + for (String id : historyItemIds) { + historyArray.add(id); + } + } + envelope.set("history_item_ids", historyArray); + + String jsonBody = MAPPER.writeValueAsString(envelope); + LOGGER.debug("Storage request body (create envelope): {}", jsonBody); + + // POST to /responses (no ID in path!) - this is the create endpoint + try (HttpResponse ignored = sendRequest(HttpMethod.POST, "responses", null, + BinaryData.fromString(jsonBody), isolation)) { + LOGGER.debug("Successfully created response {} in storage", responseId); + } + } catch (IOException e) { + LOGGER.error("Failed to create response {} in Foundry storage", responseId, e); + throw new FoundryStorageException("Failed to create response in Foundry storage", e); + } + }, ioExecutor); + } + + // ── Helpers ─────────────────────────────────────────────── + + @Override + public CompletableFuture> getResponseAsync(String responseId) { + return getResponseAsync(responseId, null); + } + + @Override + public CompletableFuture> getResponseAsync(String responseId, IsolationContext isolation) { + LOGGER.debug("getResponseAsync called for responseId={}", responseId); + return CompletableFuture.supplyAsync(() -> { + try { + String url = buildUrl("responses/" + encode(responseId), null); + HttpRequest request = new HttpRequest(HttpMethod.GET, url); + request.setHeader(HttpHeaderName.ACCEPT, "application/json"); + applyIsolationHeaders(request, isolation); + + try (HttpResponse response = pipeline.sendSync(request, com.azure.core.util.Context.NONE)) { + if (response.getStatusCode() == 404) { + LOGGER.debug("Response {} not found in Foundry storage", responseId); + return Optional.empty(); + } + throwIfError(response); + + String json = response.getBodyAsBinaryData().toString(); + Response result = MAPPER.readValue(json, Response.class); + LOGGER.debug("Successfully retrieved response {}", responseId); + return Optional.of(result); + } + } catch (IOException e) { + LOGGER.error("Failed to get response {} from Foundry storage", responseId, e); + throw new FoundryStorageException("Failed to get response from Foundry storage", e); + } + }, ioExecutor); + } + + private static void applyIsolationHeaders(HttpRequest request, IsolationContext isolation) { + if (isolation == null) { + return; + } + if (isolation.userIsolationKey() != null) { + request.setHeader(HttpHeaderName.fromString(PlatformHeaders.USER_ISOLATION_KEY), + isolation.userIsolationKey()); + } + if (isolation.chatIsolationKey() != null) { + request.setHeader(HttpHeaderName.fromString(PlatformHeaders.CHAT_ISOLATION_KEY), + isolation.chatIsolationKey()); + } + } + + @Override + public CompletableFuture deleteResponseAsync(String responseId) { + LOGGER.debug("deleteResponseAsync called for responseId={}", responseId); + return CompletableFuture.runAsync(() -> { + try (HttpResponse ignored = sendRequest(HttpMethod.DELETE, "responses/" + encode(responseId), null, null)) { + LOGGER.debug("Successfully deleted response {}", responseId); + } + }, ioExecutor); + } + + @Override + public CompletableFuture> getInputItemsForResponseAsync(String responseId) { + LOGGER.debug("getInputItemsForResponseAsync called for responseId={}", responseId); + return CompletableFuture.supplyAsync(() -> { + try { + String url = buildUrl("responses/" + encode(responseId) + "/input_items", null); + HttpRequest request = new HttpRequest(HttpMethod.GET, url); + request.setHeader(HttpHeaderName.ACCEPT, "application/json"); + + try (HttpResponse response = pipeline.sendSync(request, com.azure.core.util.Context.NONE)) { + if (response.getStatusCode() == 404) { + LOGGER.debug("Input items for response {} not found", responseId); + return List.of(); + } + throwIfError(response); + + String json = response.getBodyAsBinaryData().toString(); + JsonNode root = MAPPER.readTree(json); + JsonNode dataNode = root.has("data") ? root.get("data") : root; + List result = new ArrayList<>(); + if (dataNode.isArray()) { + for (JsonNode node : dataNode) { + if (!node.isNull()) { + result.add(MAPPER.treeToValue(node, ResponseItem.class)); + } + } + } + LOGGER.debug("getInputItemsForResponseAsync returning {} items for response {}", + result.size(), responseId); + return result; + } + } catch (IOException e) { + LOGGER.error("Failed to get input items for response {} from Foundry storage", responseId, e); + throw new FoundryStorageException("Failed to get input items from Foundry storage", e); + } + }, ioExecutor); + } + + // ── Sanitization ───────────────────────────────────────── + + /** + * Sanitizes a response JSON object for storage API compatibility. + * The OpenAI Java SDK serializes extra fields (agent_id, created_by, logprobs) + * and uses floating-point for timestamps. This method produces a body that + * matches the C#/.NET ResponseObject schema exactly. + */ + private ObjectNode sanitizeResponseForStorage(ObjectNode node) { + // Remove fields not in the storage schema + node.remove("agent_id"); + node.remove("created_by"); + node.remove("logprobs"); + node.remove("service_tier"); + node.remove("metadata"); + + // Remove camelCase duplicates (OpenAI Java SDK serializes both snake_case and camelCase) + node.remove("createdAt"); + node.remove("completedAt"); + node.remove("cancelledAt"); + node.remove("failedAt"); + node.remove("parallelToolCalls"); + node.remove("toolChoice"); + node.remove("previousResponseId"); + node.remove("maxOutputTokens"); + node.remove("incompleteDetails"); + node.remove("topP"); + + // Ensure timestamps are integers (SDK may serialize as floats) + ensureIntTimestamp(node, "created_at"); + ensureIntTimestamp(node, "completed_at"); + ensureIntTimestamp(node, "cancelled_at"); + ensureIntTimestamp(node, "failed_at"); + + // Remove fields that should not be present if null (C#/Python omit nulls) + // Only add these if they have real values; the storage API doesn't expect explicit nulls + // Storage backend (C# reference impl) writes these as explicit `null` when + // unset rather than omitting them. Ensure they are always present so + // the platform deserializer doesn't 500 on a missing required key. + ensureNullIfAbsent(node, "instructions"); + ensureNullIfAbsent(node, "error"); + ensureNullIfAbsent(node, "incomplete_details"); + // agent_reference is always set by AgentServerResponsesApi.normalizeIdsAndStamp; + // if it's missing on the way in, default to null to match the + // C# WriteNull behavior. + ensureNullIfAbsent(node, "agent_reference"); + + // These are truly optional in the C# schema (Optional.IsDefined wraps with no else) + // — omit when null/missing. + removeIfNull(node, "previous_response_id"); + removeIfNull(node, "cancelled_at"); + removeIfNull(node, "failed_at"); + + // Sanitize output items (remove openai-java-only extras; ensure schema fields) + if (node.has("output") && node.get("output").isArray()) { + for (JsonNode outputItem : node.get("output")) { + if (outputItem.isObject()) { + ObjectNode itemObj = (ObjectNode) outputItem; + itemObj.remove("created_by"); + itemObj.remove("logprobs"); + // Sanitize content parts within output items + if (itemObj.has("content") && itemObj.get("content").isArray()) { + for (JsonNode contentPart : itemObj.get("content")) { + if (contentPart.isObject()) { + ObjectNode partObj = (ObjectNode) contentPart; + // C# OutputMessageContentOutputTextContent always writes + // logprobs as an array (defaults to []). The platform + // deserializer 500s when this field is absent. + if ("output_text".equals(partObj.path("type").asText())) { + if (!partObj.has("logprobs") || partObj.get("logprobs").isNull()) { + partObj.set("logprobs", MAPPER.createArrayNode()); + } + if (!partObj.has("annotations") || partObj.get("annotations").isNull()) { + partObj.set("annotations", MAPPER.createArrayNode()); + } + } + } + } + } + } + } + } + + // Ensure usage has required sub-objects + if (node.has("usage") && !node.get("usage").isNull()) { + ObjectNode usage = (ObjectNode) node.get("usage"); + if (!usage.has("input_tokens_details") || usage.get("input_tokens_details").isNull()) { + ObjectNode inputDetails = MAPPER.createObjectNode(); + inputDetails.put("cached_tokens", 0); + usage.set("input_tokens_details", inputDetails); + } + if (!usage.has("output_tokens_details") || usage.get("output_tokens_details").isNull()) { + ObjectNode outputDetails = MAPPER.createObjectNode(); + outputDetails.put("reasoning_tokens", 0); + usage.set("output_tokens_details", outputDetails); + } + } + + return node; + } + + /** + * Ensures a timestamp field is an integer (not a float like 1.778771021E9). + */ + private void ensureIntTimestamp(ObjectNode node, String field) { + if (node.has(field) && !node.get(field).isNull()) { + JsonNode val = node.get(field); + if (val.isDouble() || val.isFloat()) { + node.put(field, val.longValue()); + } + } + } + + /** + * Removes a field from the node if it is null or missing. + * The storage API expects fields to be omitted rather than explicitly null. + */ + private void removeIfNull(ObjectNode node, String field) { + if (node.has(field) && node.get(field).isNull()) { + node.remove(field); + } + } + + /** + * Ensures the field is present in the object, set to JSON null if it was + * missing. The C# reference serializer writes these keys as explicit nulls + * rather than omitting them, and the storage backend deserializer rejects + * envelopes where they are absent. + */ + private void ensureNullIfAbsent(ObjectNode node, String field) { + if (!node.has(field)) { + node.putNull(field); + } + } + + /** + * Shuts down the I/O executor used for async HTTP operations. + * Outstanding tasks are allowed to complete, but no new tasks will be accepted. + */ + @Override + public void close() { + ioExecutor.shutdown(); + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/IdGenerator.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/IdGenerator.java new file mode 100644 index 000000000000..9b85c636a7c3 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/IdGenerator.java @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.implementation; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.regex.Pattern; + +/** + * Generates unique IDs with a category prefix, matching the Foundry ID format: + * {@code {prefix}_{partitionKey}{entropy}}. + */ +public final class IdGenerator { + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final int PARTITION_KEY_LENGTH = 18; + private static final int ENTROPY_LENGTH = 32; + + /** + * Prefix for Response resource IDs, per the protocol prefix table. + * Response IDs have the form {@code caresp_{partitionKey}{entropy}}. + */ + static final String RESPONSE_PREFIX = "caresp"; + + /** + * Pattern for a well-formed response ID: the {@code caresp} prefix, a separator, + * and a 50-character Base62 body (18-char partition key + 32-char entropy), per + * the protocol ID format. + */ + private static final Pattern RESPONSE_ID_PATTERN = + Pattern.compile(RESPONSE_PREFIX + "_[A-Za-z0-9]{" + (PARTITION_KEY_LENGTH + ENTROPY_LENGTH) + "}"); + + /** + * Returns {@code true} if {@code id} is a well-formed response ID + * ({@code caresp_} + 50 Base62 characters). Used to validate path parameters + * before lookup. + * + * @param id the candidate response ID + * @return whether the ID is well-formed + */ + public static boolean isValidResponseId(String id) { + return id != null && RESPONSE_ID_PATTERN.matcher(id).matches(); + } + + private final String partitionKey; + + public IdGenerator(String partitionKey) { + this.partitionKey = partitionKey != null ? partitionKey : secureEntropy(PARTITION_KEY_LENGTH); + } + + /** + * Extracts the partition key from an existing Foundry-format ID. + * + * @param id the existing ID (e.g. "caresp_AbCdEf...") + * @return the partition key portion + */ + public static String extractPartitionKey(String id) { + if (id == null || id.isEmpty()) { + throw new IllegalArgumentException("ID cannot be null or empty"); + } + int underscoreIdx = id.indexOf('_'); + if (underscoreIdx < 0 || id.length() < underscoreIdx + 1 + PARTITION_KEY_LENGTH) { + throw new IllegalArgumentException("ID '" + id + "' does not contain a valid partition key"); + } + return id.substring(underscoreIdx + 1, underscoreIdx + 1 + PARTITION_KEY_LENGTH); + } + + private static String secureEntropy(int length) { + byte[] bytes = new byte[length]; + RANDOM.nextBytes(bytes); + String base64 = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + // Keep only alphanumeric characters and truncate to desired length + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < base64.length() && sb.length() < length; i++) { + char c = base64.charAt(i); + if (Character.isLetterOrDigit(c)) { + sb.append(c); + } + } + // If not enough chars (unlikely), pad with more random + while (sb.length() < length) { + RANDOM.nextBytes(bytes); + base64 = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + for (int i = 0; i < base64.length() && sb.length() < length; i++) { + char c = base64.charAt(i); + if (Character.isLetterOrDigit(c)) { + sb.append(c); + } + } + } + return sb.toString(); + } + + /** + * Generates a new ID with the given category prefix. + * + * @param category the prefix, e.g. "msg", "resp", "conv", "func" + * @return a new unique ID string + */ + public String generate(String category) { + String prefix = (category == null || category.isEmpty()) ? "id" : category; + return prefix + "_" + partitionKey + secureEntropy(ENTROPY_LENGTH); + } + + /** + * Generates a new Response resource ID using the {@code caresp} prefix. + * + * @return a new unique response ID string, e.g. {@code caresp_...} + */ + public String generateResponseId() { + return generate(RESPONSE_PREFIX); + } + + public String generateMessageItemId() { + return generate("msg"); + } + + public String generateFunctionCallItemId() { + return generate("fc"); + } + + public String generateReasoningItemId() { + return generate("rs"); + } + + public String generateFileSearchCallItemId() { + return generate("fs"); + } + + public String generateWebSearchCallItemId() { + return generate("ws"); + } + + public String generateCodeInterpreterCallItemId() { + return generate("ci"); + } + + public String generateImageGenCallItemId() { + return generate("ig"); + } + + public String generateMcpCallItemId() { + return generate("mcp"); + } + + public String generateMcpListToolsItemId() { + return generate("mcplt"); + } + + public String generateCustomToolCallItemId() { + return generate("ct"); + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/InMemoryResponseProvider.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/InMemoryResponseProvider.java new file mode 100644 index 000000000000..9fb870ec6c65 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/InMemoryResponseProvider.java @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.implementation; + +import com.microsoft.agentserver.api.ResponsesProvider; +import com.openai.models.responses.ResponseItem; +import com.openai.models.responses.ResponseOutputItem; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Thread-safe, in-memory implementation of {@link ResponsesProvider}. + *

+ * Stores items as {@link ResponseItem} — a union type that naturally carries + * role information ({@code ResponseInputMessageItem} for user/system/developer, + * {@code ResponseOutputMessage} for assistant). No separate tracking of + * input vs output items is needed. + *

+ * Suitable for single-instance / development use; for production multi-instance + * deployments, implement a durable backend instead. + */ +public final class InMemoryResponseProvider implements ResponsesProvider { + + /** + * Default maximum number of responses to retain. When exceeded, the oldest + * response (by insertion order) is evicted to prevent unbounded memory growth. + * Set to 0 for unlimited (not recommended in production). + */ + static final int DEFAULT_MAX_RESPONSES = 10_000; + + private final int maxResponses; + + /** + * Insertion-order tracker for LRU eviction when {@link #maxResponses} is reached. + */ + private final ConcurrentLinkedDeque insertionOrder = new ConcurrentLinkedDeque<>(); + + /** + * Item ID → stored item (input or output). + */ + private final Map items = new ConcurrentHashMap<>(); + + /** + * Response ID → full Response object. + */ + private final Map responses = new ConcurrentHashMap<>(); + + /** + * Response ID → ordered list of item IDs belonging to that response. + */ + private final Map> responseItemIds = new ConcurrentHashMap<>(); + + /** + * Response ID → its previous response ID (for chaining). + */ + private final Map responsePreviousId = new ConcurrentHashMap<>(); + + /** + * Conversation ID → ordered list of response IDs in that conversation. + */ + private final Map> conversationResponses = new ConcurrentHashMap<>(); + + /** + * Response ID → ordered list of input item IDs for that response. + */ + private final Map> responseInputItemIds = new ConcurrentHashMap<>(); + + /** + * Extracts the item ID from a {@link ResponseItem} union type. + */ + static String extractItemId(ResponseItem item) { + return ItemConversion.extractItemId(item); + } + + /** + * Creates an in-memory provider with the default max-responses cap + * ({@value #DEFAULT_MAX_RESPONSES}). + */ + public InMemoryResponseProvider() { + this(DEFAULT_MAX_RESPONSES); + } + + /** + * Creates an in-memory provider with a custom max-responses cap. + * + * @param maxResponses the maximum number of responses to retain (0 = unlimited) + */ + public InMemoryResponseProvider(int maxResponses) { + this.maxResponses = maxResponses; + } + + /** + * Extracts the item ID from a {@link ResponseOutputItem}. + */ + static String extractOutputItemId(ResponseOutputItem item) { + return ItemConversion.extractOutputItemId(item); + } + + @Override + public CompletableFuture> getItemsAsync(List itemIds) { + List result = new ArrayList<>(itemIds.size()); + for (String id : itemIds) { + result.add(items.get(id)); // null if not found, per contract + } + return CompletableFuture.completedFuture(result); + } + + @Override + public CompletableFuture> getHistoryItemIdsAsync( + String previousResponseId, + String conversationId, + int limit) { + + List allItemIds = new ArrayList<>(); + + if (previousResponseId != null && !previousResponseId.isEmpty()) { + collectResponseChain(previousResponseId, allItemIds, limit); + } else if (conversationId != null && !conversationId.isEmpty()) { + List responseIds = conversationResponses.get(conversationId); + if (responseIds != null) { + for (String respId : responseIds) { + List ids = responseItemIds.get(respId); + if (ids != null) { + allItemIds.addAll(ids); + if (allItemIds.size() >= limit) { + break; + } + } + } + } + } + + if (allItemIds.size() > limit) { + allItemIds = allItemIds.subList(allItemIds.size() - limit, allItemIds.size()); + } + + return CompletableFuture.completedFuture(Collections.unmodifiableList(allItemIds)); + } + + /** + * Persists a completed response and its output items. + * Output items are converted to {@link ResponseItem} for uniform storage. + */ + public void saveResponse( + String responseId, + com.openai.models.responses.Response response, + String previousResponseId, + String conversationId) { + + // Track insertion order and evict the oldest when the cap is reached. + if (maxResponses > 0 && !responses.containsKey(responseId)) { + insertionOrder.addLast(responseId); + while (responses.size() >= maxResponses) { + String oldest = insertionOrder.pollFirst(); + if (oldest != null) { + evictResponse(oldest); + } + } + } + + responses.put(responseId, response); + + List itemIdList = new ArrayList<>(response.output().size()); + + for (ResponseOutputItem outputItem : response.output()) { + ResponseItem item = ItemConversion.toResponseItem(outputItem); + String itemId = extractOutputItemId(outputItem); + if (item != null && itemId != null) { + items.put(itemId, item); + itemIdList.add(itemId); + } + } + + responseItemIds.merge(responseId, itemIdList, (existing, newIds) -> { + // If input items were already stored, append output items after them + List combined = new ArrayList<>(existing.size() + newIds.size()); + combined.addAll(existing); + combined.addAll(newIds); + return combined; + }); + + if (previousResponseId != null && !previousResponseId.isEmpty()) { + responsePreviousId.put(responseId, previousResponseId); + } + + if (conversationId != null && !conversationId.isEmpty()) { + conversationResponses + .computeIfAbsent(conversationId, k -> new CopyOnWriteArrayList<>()) + .add(responseId); + } + } + + @Override + public CompletableFuture saveResponseAsync( + String responseId, + com.openai.models.responses.Response response, + String previousResponseId, + String conversationId) { + saveResponse(responseId, response, previousResponseId, conversationId); + return CompletableFuture.completedFuture(null); + } + + // ── Private helpers ───────────────────────────────────────── + + @Override + public CompletableFuture saveInputItemsAsync( + String responseId, + List inputItems) { + if (inputItems == null || inputItems.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + + List inputItemIds = new ArrayList<>(); + for (ResponseItem item : inputItems) { + String itemId = extractItemId(item); + if (itemId != null) { + items.put(itemId, item); + inputItemIds.add(itemId); + } + } + + if (!inputItemIds.isEmpty()) { + // Track input item IDs separately for getInputItemsForResponseAsync + responseInputItemIds.put(responseId, Collections.unmodifiableList(inputItemIds)); + + // Store input item IDs first; output items will be appended by saveResponse + responseItemIds.merge(responseId, inputItemIds, (existing, newIds) -> { + List combined = new ArrayList<>(newIds.size() + existing.size()); + combined.addAll(newIds); + combined.addAll(existing); + return combined; + }); + } + + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture> getResponseAsync(String responseId) { + return CompletableFuture.completedFuture(Optional.ofNullable(responses.get(responseId))); + } + + @Override + public CompletableFuture> getInputItemsForResponseAsync(String responseId) { + List inputIds = responseInputItemIds.get(responseId); + if (inputIds == null || inputIds.isEmpty()) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + List result = new ArrayList<>(inputIds.size()); + for (String id : inputIds) { + ResponseItem item = items.get(id); + if (item != null) { + result.add(item); + } + } + return CompletableFuture.completedFuture(Collections.unmodifiableList(result)); + } + + @Override + public CompletableFuture deleteResponseAsync(String responseId) { + com.openai.models.responses.Response removed = responses.remove(responseId); + if (removed != null) { + List itemIds = responseItemIds.remove(responseId); + if (itemIds != null) { + for (String itemId : itemIds) { + items.remove(itemId); + } + } + responsePreviousId.remove(responseId); + removed.conversation().ifPresent(conv -> { + List respIds = conversationResponses.get(conv.id()); + if (respIds != null) { + respIds.remove(responseId); + } + }); + } + return CompletableFuture.completedFuture(null); + } + + /** + * Evicts a single response and its associated items/metadata from all maps. + * Used by the cap-based eviction logic in {@link #saveResponse}. + */ + private void evictResponse(String responseId) { + com.openai.models.responses.Response removed = responses.remove(responseId); + if (removed != null) { + List itemIds = responseItemIds.remove(responseId); + if (itemIds != null) { + for (String itemId : itemIds) { + items.remove(itemId); + } + } + responseInputItemIds.remove(responseId); + responsePreviousId.remove(responseId); + removed.conversation().ifPresent(conv -> { + List respIds = conversationResponses.get(conv.id()); + if (respIds != null) { + respIds.remove(responseId); + } + }); + } + } + + private void collectResponseChain(String responseId, List accumulator, int limit) { + List chain = new ArrayList<>(); + java.util.Set visited = new java.util.HashSet<>(); + String current = responseId; + while (current != null && visited.add(current)) { + chain.add(current); + current = responsePreviousId.get(current); + } + Collections.reverse(chain); + + for (String respId : chain) { + List ids = responseItemIds.get(respId); + if (ids != null) { + accumulator.addAll(ids); + if (accumulator.size() >= limit) { + return; + } + } + } + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/ItemConversion.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/ItemConversion.java new file mode 100644 index 000000000000..50442c6facec --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/ItemConversion.java @@ -0,0 +1,341 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.implementation; + +import com.openai.models.responses.ResponseCodeInterpreterToolCall; +import com.openai.models.responses.ResponseComputerToolCall; +import com.openai.models.responses.ResponseFileSearchToolCall; +import com.openai.models.responses.ResponseFunctionToolCall; +import com.openai.models.responses.ResponseFunctionWebSearch; +import com.openai.models.responses.ResponseInputContent; +import com.openai.models.responses.ResponseInputItem; +import com.openai.models.responses.ResponseInputMessageItem; +import com.openai.models.responses.ResponseItem; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseOutputText; +import com.openai.models.responses.ResponseReasoningItem; + +import java.util.ArrayList; +import java.util.List; + +/** + * Internal utility for converting {@link ResponseInputItem} instances to + * {@link ResponseOutputItem} instances suitable for storage and retrieval + * via the provider. Each converted item receives a correctly prefixed ID + * via {@link IdGenerator}. + */ +public final class ItemConversion { + + private ItemConversion() { + } + + /** + * Converts a single {@link ResponseInputItem} to a {@link ResponseOutputItem}. + * Generates a type-specific ID using the supplied {@link IdGenerator} and marks + * status as completed. + * + * @param item the input item to convert. + * @param idGen the ID generator for creating type-specific IDs. + * @return the converted output item, or {@code null} if the item type is not + * convertible (e.g., {@code ItemReference}). + */ + public static ResponseOutputItem toOutputItem(ResponseInputItem item, IdGenerator idGen) { + // --- Messages --- + if (item.isMessage()) { + return convertMessage(item.asMessage(), idGen); + } + + // --- Function tool calls --- + if (item.isFunctionCall()) { + ResponseFunctionToolCall funcCall = item.asFunctionCall(); + // Re-build with a generated ID if none is present + ResponseFunctionToolCall withId = funcCall.toBuilder() + .id(funcCall.id().orElse(idGen.generateFunctionCallItemId())) + .status(ResponseFunctionToolCall.Status.COMPLETED) + .build(); + return ResponseOutputItem.ofFunctionCall(withId); + } + + // --- File search --- + if (item.isFileSearchCall()) { + ResponseFileSearchToolCall fileSearch = item.asFileSearchCall(); + return ResponseOutputItem.ofFileSearchCall(fileSearch); + } + + // --- Web search --- + if (item.isWebSearchCall()) { + ResponseFunctionWebSearch webSearch = item.asWebSearchCall(); + return ResponseOutputItem.ofWebSearchCall(webSearch); + } + + // --- Reasoning --- + if (item.isReasoning()) { + ResponseReasoningItem reasoning = item.asReasoning(); + ResponseReasoningItem withId = reasoning.toBuilder() + .id(reasoning.id()) + .status(ResponseReasoningItem.Status.COMPLETED) + .build(); + return ResponseOutputItem.ofReasoning(withId); + } + + // --- Computer tool calls --- + if (item.isComputerCall()) { + ResponseComputerToolCall computerCall = item.asComputerCall(); + return ResponseOutputItem.ofComputerCall(computerCall); + } + + // --- Code interpreter --- + if (item.isCodeInterpreterCall()) { + ResponseCodeInterpreterToolCall codeInterpreter = item.asCodeInterpreterCall(); + return ResponseOutputItem.ofCodeInterpreterCall(codeInterpreter); + } + + // --- Item references are not convertible inline --- + if (item.isItemReference()) { + return null; + } + + // --- EasyInputMessage (shorthand user/system/developer messages) --- + if (item.isEasyInputMessage()) { + return convertEasyInputMessage(item.asEasyInputMessage(), idGen); + } + + // Unknown type — not convertible + return null; + } + + /** + * Converts a sequence of {@link ResponseInputItem} to {@link ResponseOutputItem} instances. + * Items that cannot be converted inline (e.g., item references) are skipped. + * + * @param items the input items to convert. + * @param idGen the ID generator. + * @return the converted output items (references excluded). + */ + static List toOutputItems(List items, IdGenerator idGen) { + List results = new ArrayList<>(); + for (ResponseInputItem item : items) { + ResponseOutputItem output = toOutputItem(item, idGen); + if (output != null) { + results.add(output); + } + } + return results; + } + + // ── Helpers ───────────────────────────────────────────────── + + /** + * Extracts the item ID from a {@link ResponseItem} union type. + *

+ * Handles all known variants including input messages, output messages, + * tool calls, and tool outputs. + * + * @param item the response item + * @return the item ID, or {@code null} if the variant is unrecognized + */ + public static String extractItemId(ResponseItem item) { + if (item.isResponseInputMessageItem()) return item.asResponseInputMessageItem().id(); + if (item.isResponseOutputMessage()) return item.asResponseOutputMessage().id(); + if (item.isFunctionCall()) return item.asFunctionCall().id().orElse(null); + if (item.isFunctionCallOutput()) return item.asFunctionCallOutput().id(); + if (item.isFileSearchCall()) return item.asFileSearchCall().id(); + if (item.isWebSearchCall()) return item.asWebSearchCall().id(); + if (item.isComputerCall()) return item.asComputerCall().id(); + if (item.isReasoning()) return item.asReasoning().id(); + if (item.isCompaction()) return item.asCompaction().id(); + if (item.isCodeInterpreterCall()) return item.asCodeInterpreterCall().id(); + if (item.isShellCall()) return item.asShellCall().id(); + if (item.isShellCallOutput()) return item.asShellCallOutput().id(); + if (item.isApplyPatchCall()) return item.asApplyPatchCall().id(); + if (item.isApplyPatchCallOutput()) return item.asApplyPatchCallOutput().id(); + if (item.isCustomToolCall()) return item.asCustomToolCall().id().orElse(null); + return null; + } + + /** + * Extracts the item ID from a {@link ResponseOutputItem} union type. + *

+ * Handles output-only variants (message, MCP calls) directly, then + * converts shared variants to {@link ResponseItem} and delegates to + * {@link #extractItemId(ResponseItem)}. + * + * @param item the output item + * @return the item ID, or {@code null} if the variant is unrecognized + */ + public static String extractOutputItemId(ResponseOutputItem item) { + // Output-only cases (no direct ResponseItem equivalent) + if (item.isMessage()) return item.asMessage().id(); + if (item.isMcpCall()) return item.asMcpCall().id(); + if (item.isMcpListTools()) return item.asMcpListTools().id(); + if (item.isMcpApprovalRequest()) return item.asMcpApprovalRequest().id(); + + // Shared cases — convert to ResponseItem and extract + ResponseItem responseItem = toResponseItem(item); + if (responseItem != null) { + return extractItemId(responseItem); + } + + // Cases where toResponseItem returns null, but we can still extract ID + if (item.isFunctionCall()) return item.asFunctionCall().id().orElse(null); + if (item.isFunctionCallOutput()) return item.asFunctionCallOutput().id(); + if (item.isCompaction()) return item.asCompaction().id(); + if (item.isCustomToolCall()) return item.asCustomToolCall().id().orElse(null); + return null; + } + + /** + * Converts a {@link ResponseOutputItem} to a {@link ResponseItem}. + *

+ * Note: {@code FunctionCall} and {@code CustomToolCall} output items cannot be converted + * because {@link ResponseItem#ofFunctionCall} and {@link ResponseItem#ofCustomToolCall} + * expect the "item" input variants, not the output-side types. + * + * @param item the output item to convert + * @return the converted response item, or {@code null} if the type is not convertible + */ + public static ResponseItem toResponseItem(ResponseOutputItem item) { + if (item.isMessage()) return ResponseItem.ofResponseOutputMessage(item.asMessage()); + if (item.isFileSearchCall()) return ResponseItem.ofFileSearchCall(item.asFileSearchCall()); + if (item.isWebSearchCall()) return ResponseItem.ofWebSearchCall(item.asWebSearchCall()); + if (item.isComputerCall()) return ResponseItem.ofComputerCall(item.asComputerCall()); + if (item.isReasoning()) return ResponseItem.ofReasoning(item.asReasoning()); + if (item.isCodeInterpreterCall()) return ResponseItem.ofCodeInterpreterCall(item.asCodeInterpreterCall()); + if (item.isShellCall()) return ResponseItem.ofShellCall(item.asShellCall()); + if (item.isShellCallOutput()) return ResponseItem.ofShellCallOutput(item.asShellCallOutput()); + if (item.isApplyPatchCall()) return ResponseItem.ofApplyPatchCall(item.asApplyPatchCall()); + if (item.isApplyPatchCallOutput()) return ResponseItem.ofApplyPatchCallOutput(item.asApplyPatchCallOutput()); + return null; + } + + /** + * Converts a {@link ResponseInputItem} (the request-side, role-aware union) + * directly to a {@link ResponseItem}, preserving the original role + * (user/system/developer). This is the correct conversion for + * {@code input_items} sent to Foundry storage — the + * {@link #toResponseItem(ResponseOutputItem)} overload loses role information + * because {@code ResponseOutputMessage} is hardcoded to {@code assistant}. + */ + public static ResponseItem toResponseItem(ResponseInputItem item, IdGenerator idGen) { + if (item.isMessage()) { + ResponseInputItem.Message msg = item.asMessage(); + ResponseInputMessageItem.Role role = convertInputRole(msg.role().value()); + return ResponseItem.ofResponseInputMessageItem( + ResponseInputMessageItem.builder() + .id(idGen.generateMessageItemId()) + .role(role) + .content(msg.content()) + .status(ResponseInputMessageItem.Status.COMPLETED) + .build()); + } + if (item.isEasyInputMessage()) { + com.openai.models.responses.EasyInputMessage easy = item.asEasyInputMessage(); + ResponseInputMessageItem.Role role = convertEasyRole(easy.role().value()); + List content = convertEasyContent(easy.content()); + return ResponseItem.ofResponseInputMessageItem( + ResponseInputMessageItem.builder() + .id(idGen.generateMessageItemId()) + .role(role) + .content(content) + .status(ResponseInputMessageItem.Status.COMPLETED) + .build()); + } + // Fall through: tool calls / outputs / references → go through the + // ResponseOutputItem conversion which preserves their structure. + ResponseOutputItem asOutput = toOutputItem(item, idGen); + return asOutput != null ? toResponseItem(asOutput) : null; + } + + private static ResponseInputMessageItem.Role convertInputRole(ResponseInputItem.Message.Role.Value v) { + return switch (v) { + case USER -> ResponseInputMessageItem.Role.USER; + case SYSTEM -> ResponseInputMessageItem.Role.SYSTEM; + case DEVELOPER -> ResponseInputMessageItem.Role.DEVELOPER; + default -> ResponseInputMessageItem.Role.USER; + }; + } + + private static ResponseInputMessageItem.Role convertEasyRole(com.openai.models.responses.EasyInputMessage.Role.Value v) { + return switch (v) { + case USER -> ResponseInputMessageItem.Role.USER; + case SYSTEM -> ResponseInputMessageItem.Role.SYSTEM; + case DEVELOPER -> ResponseInputMessageItem.Role.DEVELOPER; + case ASSISTANT -> ResponseInputMessageItem.Role.USER; // shouldn't happen on input side; map defensively + default -> ResponseInputMessageItem.Role.USER; + }; + } + + private static List convertEasyContent(com.openai.models.responses.EasyInputMessage.Content content) { + if (content.isResponseInputMessageContentList()) { + return content.asResponseInputMessageContentList(); + } + if (content.isTextInput()) { + return List.of(ResponseInputContent.ofInputText( + com.openai.models.responses.ResponseInputText.builder() + .text(content.asTextInput()) + .build())); + } + return List.of(); + } + + private static ResponseOutputItem convertMessage(ResponseInputItem.Message message, IdGenerator idGen) { + // Convert input content to output content + List outputContent = new ArrayList<>(); + for (ResponseInputContent inputContent : message.content()) { + if (inputContent.isInputText()) { + outputContent.add(ResponseOutputMessage.Content.ofOutputText( + ResponseOutputText.builder() + .text(inputContent.asInputText().text()) + .annotations(List.of()) + .build())); + } + // Other content types (audio, image, file) are passed through as-is + // by the OpenAI API; we convert text content which is the primary case. + } + + ResponseOutputMessage outputMessage = ResponseOutputMessage.builder() + .id(idGen.generateMessageItemId()) + .status(ResponseOutputMessage.Status.COMPLETED) + .content(outputContent) + .build(); + + return ResponseOutputItem.ofMessage(outputMessage); + } + + private static ResponseOutputItem convertEasyInputMessage( + com.openai.models.responses.EasyInputMessage easyMessage, IdGenerator idGen) { + // Extract text from the easy input message content + String text = ""; + var content = easyMessage.content(); + if (content.isTextInput()) { + text = content.asTextInput(); + } else if (content.isResponseInputMessageContentList()) { + StringBuilder sb = new StringBuilder(); + for (ResponseInputContent c : content.asResponseInputMessageContentList()) { + if (c.isInputText()) { + if (!sb.isEmpty()) { + sb.append("\n"); + } + sb.append(c.asInputText().text()); + } + } + text = sb.toString(); + } + + ResponseOutputMessage outputMessage = ResponseOutputMessage.builder() + .id(idGen.generateMessageItemId()) + .status(ResponseOutputMessage.Status.COMPLETED) + .content(List.of( + ResponseOutputMessage.Content.ofOutputText( + ResponseOutputText.builder() + .text(text) + .annotations(List.of()) + .build()))) + .build(); + + return ResponseOutputItem.ofMessage(outputMessage); + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/package-info.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/package-info.java new file mode 100644 index 000000000000..835b5ac34eb7 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/implementation/package-info.java @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Package containing internal implementation details for the Azure AI Foundry Agent Server API. + *

+ * Types in this package are not part of the public API surface. They are public only so they can + * be shared across packages within the library, and may change or be removed at any time without + * notice. Application code must not depend on them directly. + */ +package com.microsoft.agentserver.api.implementation; diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/package-info.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/package-info.java new file mode 100644 index 000000000000..d54e8ee3247b --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/package-info.java @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Azure AI Foundry Agent Server API — Java adapter library. + *

+ * This package provides the server-side framework for implementing agent servers + * that run on the Azure AI Foundry hosting infrastructure. It implements the + * OpenAI Responses API protocol with extensions for the Foundry platform. + * + *

Key types

+ *
    + *
  • {@link com.microsoft.agentserver.api.ResponseHandler} — interface to implement + * for handling response creation requests (streaming and synchronous)
  • + *
  • {@link com.microsoft.agentserver.api.AgentServerResponseEventStream} — fluent builder for + * constructing streaming SSE event sequences
  • + *
  • {@link com.microsoft.agentserver.api.ResponseContext} — per-request context + * providing response ID, conversation history, and input item resolution
  • + *
  • {@link com.microsoft.agentserver.api.ResponsesProvider} — pluggable persistence + * interface for response state storage
  • + *
  • {@link com.microsoft.agentserver.api.FoundryEnvironment} — strongly-typed access + * to Foundry platform environment variables
  • + *
+ * + *

Quick start

+ *
    + *
  1. Implement {@link com.microsoft.agentserver.api.ResponseHandler}
  2. + *
  3. Register your handler with {@link com.microsoft.agentserver.api.AgentServerResponsesApi}
  4. + *
  5. Deploy as a container with the Foundry platform environment variables set
  6. + *
+ * + * @see com.microsoft.agentserver.api.ResponseHandler + * @see com.microsoft.agentserver.api.AgentServerResponseEventStream + */ +package com.microsoft.agentserver.api; + diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/serialization/ObjectMapperFactory.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/serialization/ObjectMapperFactory.java new file mode 100644 index 000000000000..aed885c3c975 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/serialization/ObjectMapperFactory.java @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.serialization; + +import com.fasterxml.jackson.annotation.JsonFilter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; +import com.fasterxml.jackson.databind.ser.PropertyWriter; +import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; +import com.openai.core.JsonNull; +import com.openai.core.KnownValue; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreatedEvent; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Optional; + +/** + * Factory for a pre-configured {@link ObjectMapper} suitable for serializing + * OpenAI SDK model types to JSON. + *

+ * The mapper is configured to: + *

    + *
  • Exclude null values from serialization
  • + *
  • Skip {@link JsonNull} sentinel fields emitted by the OpenAI SDK
  • + *
  • Serialize {@code createdAt} timestamps as integers via mixin
  • + *
  • Hide the internal {@code valid}/{@code isValid} validation properties
  • + *
+ *

+ * This class is framework-agnostic. For JAX-RS integration, use the + * {@code ObjectMapperProvider} from the {@code java-agent-server-api-jaxrs} module. + */ +public final class ObjectMapperFactory { + + private static final ObjectMapper INSTANCE = createMapper(); + + private ObjectMapperFactory() { + // Static utility class + } + + /** + * Returns the shared, pre-configured {@link ObjectMapper} instance. + * + * @return the configured ObjectMapper + */ + public static ObjectMapper getObjectMapper() { + return INSTANCE; + } + + private static ObjectMapper createMapper() { + return JsonMapper.builder() + .defaultPropertyInclusion(JsonInclude.Value.construct(JsonInclude.Include.NON_NULL, JsonInclude.Include.USE_DEFAULTS)) + .configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, true) + .addModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()) + .addModule(new com.fasterxml.jackson.datatype.jdk8.Jdk8Module()) + .addMixIn(Response.class, ResponseMixin.class) + .addMixIn(ResponseCreatedEvent.class, ResponseStreamEventMixin.class) + .addMixIn(KnownValue.class, ResponseStreamEventMixin.class) + // IgnoreValidMixin only carries @JsonIgnore for valid/isValid/getValid — + // safe on Object.class because @JsonIgnore on non-existent methods is a no-op. + // The @JsonFilter is intentionally NOT on this mixin to avoid applying the + // JsonNull property filter to every type in the JVM (non-SDK types never + // contain JsonNull sentinel values and don't need the filter overhead). + .addMixIn(Object.class, IgnoreValidMixin.class) + .filterProvider(new SimpleFilterProvider() + .addFilter("ignoreJsonNull", new JsonNullPropertyFilter()) + .setFailOnUnknownId(false)) + .build(); + } + + @JsonFilter("ignoreJsonNull") + @JsonInclude(JsonInclude.Include.NON_NULL) + public interface ResponseStreamEventMixin { + } + + /** + * Mixin for {@link Response} that serializes timestamp fields as whole numbers + * (Unix epoch seconds) instead of decimals. + */ + @JsonFilter("ignoreJsonNull") + @JsonInclude(JsonInclude.Include.NON_NULL) + public interface ResponseMixin { + @JsonSerialize(using = DoubleAsIntegerSerializer.class) + double createdAt(); + + @JsonSerialize(using = OptionalDoubleAsIntegerSerializer.class) + Optional completedAt(); + } + + /** + * Mixin applied to {@code Object.class} to suppress the OpenAI SDK's internal + * {@code valid}/{@code isValid}/{@code getValid} validation properties from + * JSON serialization. + *

+ * This mixin intentionally does not carry {@code @JsonFilter} — the + * {@link JsonNullPropertyFilter} is only needed for OpenAI SDK model types + * (which get it via {@link ResponseMixin} or {@link ResponseStreamEventMixin}). + * Applying the filter globally to {@code Object.class} would add unnecessary + * overhead to every non-SDK type serialized through this mapper. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public interface IgnoreValidMixin { + @JsonIgnore + boolean valid(); + + @JsonIgnore + boolean isValid(); + + @JsonIgnore + boolean getValid(); + } + + /** + * Serializer that writes {@link Double} values as plain integers (no decimal point). + * Applied via mixin to specific fields like {@code createdAt} (Unix timestamps). + */ + private static class DoubleAsIntegerSerializer extends JsonSerializer { + @Override + public void serialize(Double value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeNumber(BigDecimal.valueOf(value).toBigInteger().toString()); + } + } + + /** + * Serializer that writes {@link Optional}{@code } values as plain integers (no decimal point), + * or omits the field when the Optional is empty. + * Applied via mixin to optional timestamp fields like {@code completedAt}. + */ + private static class OptionalDoubleAsIntegerSerializer extends JsonSerializer> { + @Override + public void serialize(Optional value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + if (value.isPresent()) { + gen.writeNumber(BigDecimal.valueOf(value.get()).toBigInteger().toString()); + } else { + gen.writeNull(); + } + } + + @Override + public boolean isEmpty(SerializerProvider provider, Optional value) { + return value == null || value.isEmpty(); + } + } + + /** + * Property filter that suppresses fields whose runtime value is a {@link JsonNull} sentinel. + */ + private static class JsonNullPropertyFilter extends SimpleBeanPropertyFilter { + @Override + public void serializeAsField(Object pojo, JsonGenerator jgen, SerializerProvider provider, PropertyWriter writer) throws Exception { + if (writer instanceof BeanPropertyWriter bpw) { + Object value = bpw.get(pojo); + if (value instanceof JsonNull) { + return; + } + } + super.serializeAsField(pojo, jgen, provider, writer); + } + } +} diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/BackgroundResponseIntegrationTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/BackgroundResponseIntegrationTest.java new file mode 100644 index 000000000000..84ccf2a80419 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/BackgroundResponseIntegrationTest.java @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.openai.core.JsonMissing; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseOutputText; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ToolChoiceOptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@code background=true} response creation (Responses API behaviour + * contract matrix C3 / Rules ) and its interaction with cancel. + * + *

+ * Synchronisation is deterministic: the test handler blocks on a {@link CountDownLatch} + * gate so the test controls exactly when background processing finishes. + */ +class BackgroundResponseIntegrationTest { + + private ResponsesProvider provider; + + @BeforeEach + void setUp() { + provider = ResponsesProvider.inMemory(); + } + + private static AgentServerCreateResponse backgroundRequest(boolean store) { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("hello") + .model("test-model") + .background(true) + .store(store) + .build() + ._body(); + return new AgentServerCreateResponse(null, body); + } + + /** + * Handler whose {@code createResponse} blocks on {@code gate} until released. + */ + private static final class GatedHandler implements ResponseHandler { + final CountDownLatch gate = new CountDownLatch(1); + final CountDownLatch handlerReturned = new CountDownLatch(1); + + @Override + public CreateResponse createResponse(ResponseContext ctx, AgentServerCreateResponse request) { + try { + if (!gate.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("gate not released in time"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + Response resp = completed(ctx.getResponseId()); + handlerReturned.countDown(); + return new CreateResponse(null, resp); + } + + @Override + public ResponseEventStream createAsync(ResponseContext ctx, AgentServerCreateResponse request) { + throw new UnsupportedOperationException("not used"); + } + } + + private static Response completed(String responseId) { + ResponseOutputMessage message = ResponseOutputMessage.builder() + .id("om_" + responseId) + .status(ResponseOutputMessage.Status.COMPLETED) + .addContent(ResponseOutputMessage.Content.ofOutputText( + ResponseOutputText.builder().text("done").annotations(List.of()).build())) + .build(); + return Response.builder() + .id(responseId) + .createdAt(System.currentTimeMillis() / 1000.0) + .addOutput(ResponseOutputItem.ofMessage(message)) + .model("test-model") + .parallelToolCalls(false) + .tools(List.of()) + .status(ResponseStatus.COMPLETED) + .background(true) + .error(JsonMissing.of()) + .incompleteDetails(JsonMissing.of()) + .instructions(JsonMissing.of()) + .metadata(JsonMissing.of()) + .temperature(JsonMissing.of()) + .topP(JsonMissing.of()) + .toolChoice(ToolChoiceOptions.AUTO) + .build(); + } + + private ResponseStatus pollUntil(ResponsesApi api, String id, ResponseStatus expected) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (System.nanoTime() < deadline) { + ResponseStatus status = api.getResponse(id, List.of()).status().orElse(null); + if (expected.equals(status)) { + return status; + } + TimeUnit.MILLISECONDS.sleep(20); + } + fail("Response " + id + " did not reach " + expected + " within timeout"); + return null; // unreachable + } + + @Test + @DisplayName("background=true + store=false → 400") + void backgroundRequiresStore() { + ResponsesApi api = ResponsesApi.builder() + .responseHandler(new GatedHandler()) + .provider(provider) + .build(); + + ApiException ex = assertThrows(ApiException.class, () -> api.createResponse(backgroundRequest(false))); + assertEquals(400, ex.getStatusCode()); + } + + @Test + @DisplayName("background=true returns in_progress immediately; GET reflects in_progress then completed") + void backgroundReturnsInProgressThenCompletes() throws Exception { + GatedHandler handler = new GatedHandler(); + ResponsesApi api = ResponsesApi.builder().responseHandler(handler).provider(provider).build(); + + CreateResponse created = api.createResponse(backgroundRequest(true)); + String id = created.response().id(); + + // immediate return with in_progress while the handler is still blocked. + assertEquals(Optional.of(ResponseStatus.IN_PROGRESS), created.response().status()); + assertEquals(Optional.of(ResponseStatus.IN_PROGRESS), + api.getResponse(id, List.of()).status()); + + // Release the handler and verify the response transitions to completed. + handler.gate.countDown(); + pollUntil(api, id, ResponseStatus.COMPLETED); + assertTrue(api.getResponse(id, List.of()).status().isPresent()); + } + + @Test + @DisplayName("Cancel during background processing wins; later completion does not overwrite") + void cancelDuringBackgroundWins() throws Exception { + GatedHandler handler = new GatedHandler(); + ResponsesApi api = ResponsesApi.builder().responseHandler(handler).provider(provider).build(); + + CreateResponse created = api.createResponse(backgroundRequest(true)); + String id = created.response().id(); + assertEquals(Optional.of(ResponseStatus.IN_PROGRESS), + api.getResponse(id, List.of()).status()); + + // Cancel while in-flight → cancelled, output cleared. + Response cancelled = api.cancelResponse(id); + assertEquals(Optional.of(ResponseStatus.CANCELLED), cancelled.status()); + assertTrue(cancelled.output().isEmpty()); + + // Release the handler; the background finaliser must NOT overwrite the cancel. + handler.gate.countDown(); + assertTrue(handler.handlerReturned.await(10, TimeUnit.SECONDS), + "handler should have returned"); + + // Cancelled state is authoritative and stable. + assertEquals(Optional.of(ResponseStatus.CANCELLED), + api.getResponse(id, List.of()).status()); + assertTrue(api.getResponse(id, List.of()).output().isEmpty()); + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/CancelResponseIntegrationTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/CancelResponseIntegrationTest.java new file mode 100644 index 000000000000..d99ee9df02db --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/CancelResponseIntegrationTest.java @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.microsoft.agentserver.api.implementation.InMemoryResponseProvider; +import com.openai.core.JsonMissing; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseOutputText; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ToolChoiceOptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link ResponsesApi#cancelResponse(String)} covering the cancel + * behaviour matrix from the API spec ( / + * Rules ). + */ +class CancelResponseIntegrationTest { + + private InMemoryResponseProvider provider; + private ResponsesApi api; + + @BeforeEach + void setUp() { + provider = new InMemoryResponseProvider(); + // The handler is never invoked by cancelResponse; a no-op is sufficient. + ResponseHandler noopHandler = new ResponseHandler() { + @Override + public CreateResponse createResponse(ResponseContext ctx, AgentServerCreateResponse request) { + throw new UnsupportedOperationException("not used"); + } + + @Override + public ResponseEventStream createAsync(ResponseContext ctx, AgentServerCreateResponse request) { + throw new UnsupportedOperationException("not used"); + } + }; + api = ResponsesApi.builder().responseHandler(noopHandler).provider(provider).build(); + } + + private void seed(String id, boolean background, ResponseStatus status) { + ResponseOutputMessage message = ResponseOutputMessage.builder() + .id("om_" + id) + .status(ResponseOutputMessage.Status.COMPLETED) + .addContent(ResponseOutputMessage.Content.ofOutputText( + ResponseOutputText.builder().text("hi").annotations(List.of()).build())) + .build(); + + Response resp = Response.builder() + .id(id) + .createdAt(System.currentTimeMillis() / 1000.0) + .addOutput(ResponseOutputItem.ofMessage(message)) + .model("test") + .parallelToolCalls(false) + .tools(List.of()) + .status(status) + .background(background) + .error(JsonMissing.of()) + .incompleteDetails(JsonMissing.of()) + .instructions(JsonMissing.of()) + .metadata(JsonMissing.of()) + .temperature(JsonMissing.of()) + .topP(JsonMissing.of()) + .toolChoice(ToolChoiceOptions.AUTO) + .build(); + + provider.saveResponseAsync(id, resp, null, null).join(); + } + + /** + * Builds a well-formed response ID (caresp_ + 50 Base62 chars) from a label. + */ + private static String vid(String label) { + String body = (label.replaceAll("[^A-Za-z0-9]", "") + "A".repeat(50)).substring(0, 50); + return "caresp_" + body; + } + + @Test + @DisplayName("Cancel unknown response → 404") + void cancelNotFound() { + ApiException ex = assertThrows(ApiException.class, () -> api.cancelResponse(vid("missing"))); + assertEquals(404, ex.getStatusCode()); + } + + @Test + @DisplayName("Cancel non-background response → 400 'Cannot cancel a synchronous response.'") + void cancelSynchronous() { + String id = vid("sync"); + seed(id, false, ResponseStatus.COMPLETED); + ApiException ex = assertThrows(ApiException.class, () -> api.cancelResponse(id)); + assertEquals(400, ex.getStatusCode()); + assertEquals("Cannot cancel a synchronous response.", ex.getError().message()); + } + + @Test + @DisplayName("Cancel background in_progress → 200, status cancelled, 0 output items") + void cancelInProgress() throws Exception { + String id = vid("inprog"); + seed(id, true, ResponseStatus.IN_PROGRESS); + Response cancelled = api.cancelResponse(id); + assertEquals(java.util.Optional.of(ResponseStatus.CANCELLED), cancelled.status()); + assertTrue(cancelled.output().isEmpty(), "output must be cleared on cancel"); + + // The stored response reflects the cancelled state. + Response stored = provider.getResponseAsync(id).join().orElseThrow(); + assertEquals(java.util.Optional.of(ResponseStatus.CANCELLED), stored.status()); + assertTrue(stored.output().isEmpty()); + } + + @Test + @DisplayName("Cancel background queued → 200, status cancelled") + void cancelQueued() throws Exception { + String id = vid("queued"); + seed(id, true, ResponseStatus.QUEUED); + Response cancelled = api.cancelResponse(id); + assertEquals(java.util.Optional.of(ResponseStatus.CANCELLED), cancelled.status()); + assertTrue(cancelled.output().isEmpty()); + } + + @Test + @DisplayName("Cancel already-cancelled response → 200 idempotent, unchanged") + void cancelIdempotent() throws Exception { + String id = vid("cancelled"); + seed(id, true, ResponseStatus.CANCELLED); + Response before = provider.getResponseAsync(id).join().orElseThrow(); + Response result = api.cancelResponse(id); + assertSame(before, result, "idempotent cancel returns the stored response unchanged"); + } + + @Test + @DisplayName("Cancel completed background response → 400 'Cannot cancel a completed response.'") + void cancelCompleted() { + String id = vid("done"); + seed(id, true, ResponseStatus.COMPLETED); + ApiException ex = assertThrows(ApiException.class, () -> api.cancelResponse(id)); + assertEquals(400, ex.getStatusCode()); + assertEquals("Cannot cancel a completed response.", ex.getError().message()); + } + + @Test + @DisplayName("Cancel failed background response → 400 'Cannot cancel a failed response.'") + void cancelFailed() { + String id = vid("failed"); + seed(id, true, ResponseStatus.FAILED); + ApiException ex = assertThrows(ApiException.class, () -> api.cancelResponse(id)); + assertEquals(400, ex.getStatusCode()); + assertEquals("Cannot cancel a failed response.", ex.getError().message()); + } + + @Test + @DisplayName("Cancel incomplete background response → 400 terminal state") + void cancelIncomplete() { + String id = vid("incomplete"); + seed(id, true, ResponseStatus.INCOMPLETE); + ApiException ex = assertThrows(ApiException.class, () -> api.cancelResponse(id)); + assertEquals(400, ex.getStatusCode()); + assertEquals("Cannot cancel a response in terminal state.", ex.getError().message()); + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ClientDisconnectIntegrationTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ClientDisconnectIntegrationTest.java new file mode 100644 index 000000000000..4ac44ec03010 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ClientDisconnectIntegrationTest.java @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.openai.core.JsonMissing; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseOutputText; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ToolChoiceOptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link ResponsesApi#signalClientDisconnected(String)}: + * client disconnect cancels non-background responses, while + * background responses outlive the connection. + */ +class ClientDisconnectIntegrationTest { + + private ResponsesProvider provider; + + @BeforeEach + void setUp() { + provider = ResponsesProvider.inMemory(); + } + + /** + * Builds a well-formed response ID (caresp_ + 50 chars) from a label. + */ + private static String vid(String label) { + String body = (label.replaceAll("[^A-Za-z0-9]", "") + "A".repeat(50)).substring(0, 50); + return "caresp_" + body; + } + + private static AgentServerCreateResponse request(boolean background) { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("hi") + .model("test-model") + .background(background) + .build() + ._body(); + return new AgentServerCreateResponse(null, body); + } + + private static Response seedInProgress(String id, boolean background) { + ResponseOutputMessage message = ResponseOutputMessage.builder() + .id("om_" + id) + .status(ResponseOutputMessage.Status.COMPLETED) + .addContent(ResponseOutputMessage.Content.ofOutputText( + ResponseOutputText.builder().text("hi").annotations(List.of()).build())) + .build(); + return Response.builder() + .id(id) + .createdAt(System.currentTimeMillis() / 1000.0) + .addOutput(ResponseOutputItem.ofMessage(message)) + .model("test") + .parallelToolCalls(false) + .tools(List.of()) + .status(ResponseStatus.IN_PROGRESS) + .background(background) + .error(JsonMissing.of()) + .incompleteDetails(JsonMissing.of()) + .instructions(JsonMissing.of()) + .metadata(JsonMissing.of()) + .temperature(JsonMissing.of()) + .topP(JsonMissing.of()) + .toolChoice(ToolChoiceOptions.AUTO) + .build(); + } + + /** + * Handler that never returns until the stream is manually completed externally. + */ + private static final class NoopHandler implements ResponseHandler { + @Override + public CreateResponse createResponse(ResponseContext ctx, AgentServerCreateResponse req) { + throw new UnsupportedOperationException("not used"); + } + + @Override + public ResponseEventStream createAsync(ResponseContext ctx, AgentServerCreateResponse req) { + throw new UnsupportedOperationException("not used"); + } + } + + @Test + @DisplayName("Unknown response ID → no-op (no exception)") + void unknownResponseIsNoOp() { + ResponsesApi api = ResponsesApi.builder() + .responseHandler(new NoopHandler()).provider(provider).build(); + // Just must not throw. + api.signalClientDisconnected(vid("notreal")); + } + + @Test + @DisplayName("Non-background in-flight → registers in tracker via createStreamingResponse, disconnect persists cancelled") + void disconnectCancelsNonBackgroundStreaming() throws Exception { + ResponsesApi api = ResponsesApi.builder() + .responseHandler(new ResponseHandler() { + @Override + public ResponseEventStream createAsync(ResponseContext ctx, AgentServerCreateResponse r) { + // Returns a never-completing stream so the execution stays in-flight. + return ResponseEventStream.create(ctx, r); + } + }) + .provider(provider) + .build(); + + ResponseEventStream stream = api.createStreamingResponse(request(false)); + String id = stream.getResponse().id(); + + // Pre-seed an in-progress snapshot (the handler would normally emit this). + provider.saveResponseAsync(id, seedInProgress(id, false), null, null).join(); + + api.signalClientDisconnected(id); + + Response stored = provider.getResponseAsync(id).join().orElseThrow(); + assertEquals(Optional.of(ResponseStatus.CANCELLED), stored.status()); + assertTrue(stored.output().isEmpty(), "output must be cleared on disconnect cancel"); + } + + @Test + @DisplayName("Background response → disconnect is a no-op") + void disconnectIsNoOpForBackground() throws Exception { + // Background create runs on a daemon executor; use a latch so we control + // exactly when the handler returns (deterministic, no Thread.sleep). + CountDownLatch handlerGate = new CountDownLatch(1); + ResponsesApi api = ResponsesApi.builder() + .responseHandler(new ResponseHandler() { + @Override + public CreateResponse createResponse(ResponseContext ctx, AgentServerCreateResponse r) { + try { + if (!handlerGate.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("handler gate not released"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + Response done = seedInProgress(ctx.getResponseId(), true).toBuilder() + .status(ResponseStatus.COMPLETED).build(); + return new CreateResponse(null, done); + } + }) + .provider(provider) + .build(); + + CreateResponse created = api.createResponse(request(true)); + String id = created.response().id(); + // Pre-state: in_progress (immediate-return snapshot persisted by the API). + assertEquals(Optional.of(ResponseStatus.IN_PROGRESS), + provider.getResponseAsync(id).join().orElseThrow().status()); + + // disconnect must NOT affect a background, in-flight response. + api.signalClientDisconnected(id); + + Response after = provider.getResponseAsync(id).join().orElseThrow(); + assertEquals(Optional.of(ResponseStatus.IN_PROGRESS), after.status(), + "background response must outlive the connection"); + // The initial in_progress snapshot starts with 0 output items, so the only + // assertion that matters here is that status was NOT flipped to cancelled. + + // Cleanly release the handler so the daemon thread exits. + handlerGate.countDown(); + } +} + + + + diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/EndToEndIntegrationTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/EndToEndIntegrationTest.java new file mode 100644 index 000000000000..1ebedee8df8b --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/EndToEndIntegrationTest.java @@ -0,0 +1,507 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseOutputText; +import com.openai.models.responses.ResponseStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * End-to-end integration tests that exercise the full pipeline: + * Request deserialization → Handler → ResponsesApi orchestration → Storage → Retrieval + *

+ * These tests simulate realistic agent scenarios with multiple turns, + * streaming handlers, error handling, and concurrent request processing. + */ +class EndToEndIntegrationTest { + + private ResponsesProvider provider; + + @BeforeEach + void setUp() { + provider = ResponsesProvider.inMemory(); + } + + private static AgentServerCreateResponse createRequest(String text) { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input(text) + .model("test-model") + .build() + ._body(); + return new AgentServerCreateResponse(null, body); + } + + private static AgentServerCreateResponse createRequestWithPrevious(String text, String previousResponseId) { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input(text) + .model("test-model") + .previousResponseId(previousResponseId) + .build() + ._body(); + return new AgentServerCreateResponse(null, body); + } + + // ══════════════════════════════════════════════════════════════ + // Multi-turn conversation simulation + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Multi-turn conversation E2E") + class MultiTurnConversation { + + private ResponsesApi api; + + @BeforeEach + void setUp() { + // Echo handler: returns the input text as the response + ResponseHandler echoHandler = new ResponseHandler() { + @Override + public CreateResponse createResponse(ResponseContext ctx, AgentServerCreateResponse request) { + String inputText = request.inputText(); + if (inputText.isEmpty()) inputText = "Echo: (structured input)"; + ResponseOutputText outputText = ResponseOutputText.builder() + .text("Echo: " + inputText) + .annotations(List.of()) + .build(); + Response resp = ResponseBuilder.convertOutputToResponse(request, outputText); + return new CreateResponse(null, resp); + } + }; + api = ResponsesApi.builder() + .responseHandler(echoHandler) + .provider(provider) + .build(); + } + + @Test + @DisplayName("Three-turn conversation: create, follow-up, follow-up") + void threeTurnConversation() throws ApiException { + // Turn 1 + CreateResponse turn1 = api.createResponse(createRequest("Hello")); + assertNotNull(turn1.response()); + String turn1Id = turn1.response().id(); + + // Turn 2: references turn 1 + CreateResponse turn2 = api.createResponse( + createRequestWithPrevious("How are you?", turn1Id)); + String turn2Id = turn2.response().id(); + + // Turn 3: references turn 2 + CreateResponse turn3 = api.createResponse( + createRequestWithPrevious("What about tomorrow?", turn2Id)); + String turn3Id = turn3.response().id(); + + // All three should be retrievable + assertNotNull(api.getResponse(turn1Id, List.of())); + assertNotNull(api.getResponse(turn2Id, List.of())); + assertNotNull(api.getResponse(turn3Id, List.of())); + + // All IDs should be distinct + assertNotEquals(turn1Id, turn2Id); + assertNotEquals(turn2Id, turn3Id); + } + + @Test + @DisplayName("Delete middle of conversation chain, endpoints remain accessible") + void deleteMiddleOfChain() throws ApiException { + CreateResponse turn1 = api.createResponse(createRequest("First")); + CreateResponse turn2 = api.createResponse( + createRequestWithPrevious("Second", turn1.response().id())); + CreateResponse turn3 = api.createResponse( + createRequestWithPrevious("Third", turn2.response().id())); + + // Delete the middle response + api.deleteResponse(turn2.response().id()); + + // First and third should still be accessible + assertNotNull(api.getResponse(turn1.response().id(), List.of())); + assertNotNull(api.getResponse(turn3.response().id(), List.of())); + + // Middle should be gone + assertThrows(ApiException.class, + () -> api.getResponse(turn2.response().id(), List.of())); + } + } + + // ══════════════════════════════════════════════════════════════ + // Streaming handler E2E + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Streaming handler E2E") + class StreamingHandlerE2E { + + @Test + @DisplayName("Streaming handler that emits multiple chunks assembles correct final response") + void streamingMultiChunkResponse() throws ApiException { + ResponseHandler chunkingHandler = new ResponseHandler() { + @Override + public ResponseEventStream createAsync(ResponseContext ctx, AgentServerCreateResponse request) { + ResponseEventStream stream = ResponseEventStream.create(ctx, request); + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg + .emitAdded() + .addTextPart(text -> { + text.emitAdded(); + // Simulate chunked response + String[] chunks = {"The ", "answer ", "is ", "42."}; + for (String chunk : chunks) { + text.emitDelta(chunk); + } + text.emitDone("The answer is 42."); + }) + .emitDone()) + .emitCompleted(); + return stream; + } + }; + + ResponsesApi api = ResponsesApi.builder() + .responseHandler(chunkingHandler) + .provider(provider) + .build(); + + ResponseEventStream stream = api.createStreamingResponse(createRequest("What is the answer?")); + + // Verify all delta events + List eventNames = stream.getEvents().stream() + .map(ResponseEvent::eventName) + .toList(); + + long deltaCount = eventNames.stream() + .filter("response.output_text.delta"::equals) + .count(); + assertEquals(4, deltaCount, "Should have 4 text delta events"); + + // Final snapshot should have the complete text + Response snapshot = stream.getResponse(); + assertEquals(java.util.Optional.of(ResponseStatus.COMPLETED), snapshot.status()); + assertEquals(1, snapshot.output().size()); + assertTrue(snapshot.output().get(0).isMessage()); + } + + @Test + @DisplayName("Streaming handler with function call + message output") + void streamingFunctionCallAndMessage() throws ApiException { + ResponseHandler handler = new ResponseHandler() { + @Override + public ResponseEventStream createAsync(ResponseContext ctx, AgentServerCreateResponse request) { + ResponseEventStream stream = ResponseEventStream.create(ctx, request); + stream.emitCreated() + .emitInProgress() + // First: function call + .addOutputFunctionCall(func -> func + .emitAdded("get_time", "call_time_1") + .emitArgumentsDelta("{}") + .emitArgumentsDone("get_time", "{}") + .emitDone()) + // Second: message with the result + .addOutputMessage(msg -> msg + .outputItemMessage("The current time is 3:00 PM.")) + .emitCompleted(); + return stream; + } + }; + + ResponsesApi api = ResponsesApi.builder() + .responseHandler(handler) + .provider(provider) + .build(); + + ResponseEventStream stream = api.createStreamingResponse(createRequest("What time is it?")); + Response snapshot = stream.getResponse(); + + assertEquals(2, snapshot.output().size()); + assertTrue(snapshot.output().get(0).isFunctionCall()); + assertTrue(snapshot.output().get(1).isMessage()); + } + } + + // ══════════════════════════════════════════════════════════════ + // Error handling E2E + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Error handling E2E") + class ErrorHandlingE2E { + + @Test + @DisplayName("Handler that throws exception propagates through API") + void handlerExceptionPropagates() { + ResponseHandler failingHandler = new ResponseHandler() { + @Override + public CreateResponse createResponse(ResponseContext ctx, AgentServerCreateResponse request) { + throw new RuntimeException("Simulated handler failure"); + } + }; + + ResponsesApi api = ResponsesApi.builder() + .responseHandler(failingHandler) + .provider(provider) + .build(); + + assertThrows(RuntimeException.class, + () -> api.createResponse(createRequest("This will fail"))); + } + + @Test + @DisplayName("Streaming handler that emits failure produces failed response") + void streamingFailure() throws ApiException { + ResponseHandler failHandler = new ResponseHandler() { + @Override + public ResponseEventStream createAsync(ResponseContext ctx, AgentServerCreateResponse request) { + ResponseEventStream stream = ResponseEventStream.create(ctx, request); + stream.emitCreated() + .emitInProgress() + .emitFailed(com.openai.models.responses.ResponseError.Code.SERVER_ERROR, + "Something went wrong"); + return stream; + } + }; + + ResponsesApi api = ResponsesApi.builder() + .responseHandler(failHandler) + .provider(provider) + .build(); + + ResponseEventStream stream = api.createStreamingResponse(createRequest("Fail please")); + Response snapshot = stream.getResponse(); + + assertEquals(java.util.Optional.of(ResponseStatus.FAILED), snapshot.status()); + assertTrue(snapshot.error().isPresent()); + assertEquals("Something went wrong", snapshot.error().get().message()); + } + } + + // ══════════════════════════════════════════════════════════════ + // Concurrent request processing + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Concurrent request processing") + class ConcurrentRequests { + + @Test + @DisplayName("Multiple concurrent sync requests all succeed") + void concurrentSyncRequests() throws Exception { + ResponseHandler echoHandler = new ResponseHandler() { + @Override + public CreateResponse createResponse(ResponseContext ctx, AgentServerCreateResponse request) { + ResponseOutputText outputText = ResponseOutputText.builder() + .text("Echo: " + request.inputText()) + .annotations(List.of()) + .build(); + Response resp = ResponseBuilder.convertOutputToResponse(request, outputText); + return new CreateResponse(null, resp); + } + }; + + ResponsesApi api = ResponsesApi.builder() + .responseHandler(echoHandler) + .provider(provider) + .build(); + + int numRequests = 20; + ExecutorService executor = Executors.newFixedThreadPool(4); + ConcurrentHashMap responseIds = new ConcurrentHashMap<>(); + CountDownLatch latch = new CountDownLatch(numRequests); + CopyOnWriteArrayList errors = new CopyOnWriteArrayList<>(); + + for (int i = 0; i < numRequests; i++) { + final int idx = i; + executor.submit(() -> { + try { + CreateResponse resp = api.createResponse( + createRequest("Request " + idx)); + responseIds.put(resp.response().id(), "Request " + idx); + } catch (Throwable t) { + errors.add(t); + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(30, TimeUnit.SECONDS)); + assertTrue(errors.isEmpty(), "Got errors: " + errors); + assertEquals(numRequests, responseIds.size(), + "All requests should produce unique response IDs"); + + executor.shutdown(); + } + + @Test + @DisplayName("Concurrent streaming requests all complete independently") + void concurrentStreamingRequests() throws Exception { + ResponseHandler handler = new ResponseHandler() { + @Override + public ResponseEventStream createAsync(ResponseContext ctx, AgentServerCreateResponse request) { + ResponseEventStream stream = ResponseEventStream.create(ctx, request); + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg.outputItemMessage("Response for: " + request.inputText())) + .emitCompleted(); + return stream; + } + }; + + ResponsesApi api = ResponsesApi.builder() + .responseHandler(handler) + .provider(provider) + .build(); + + int numRequests = 10; + ExecutorService executor = Executors.newFixedThreadPool(4); + CopyOnWriteArrayList responses = new CopyOnWriteArrayList<>(); + CountDownLatch latch = new CountDownLatch(numRequests); + + for (int i = 0; i < numRequests; i++) { + final int idx = i; + executor.submit(() -> { + try { + ResponseEventStream stream = api.createStreamingResponse( + createRequest("Stream " + idx)); + responses.add(stream.getResponse()); + } catch (ApiException e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(30, TimeUnit.SECONDS)); + assertEquals(numRequests, responses.size()); + + // All should be completed + for (Response r : responses) { + assertEquals(java.util.Optional.of(ResponseStatus.COMPLETED), r.status()); + } + + // All IDs should be unique + long uniqueIds = responses.stream().map(Response::id).distinct().count(); + assertEquals(numRequests, uniqueIds); + + executor.shutdown(); + } + } + + // ══════════════════════════════════════════════════════════════ + // ResponseBuilder utility + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("ResponseBuilder utility") + class ResponseBuilderTests { + + @Test + @DisplayName("convertOutputToResponse builds a complete response with all required fields") + void convertOutputToResponseComplete() { + AgentServerCreateResponse request = createRequest("Hello"); + ResponseOutputText text = ResponseOutputText.builder() + .text("World") + .annotations(List.of()) + .build(); + + Response resp = ResponseBuilder.convertOutputToResponse(request, text); + assertNotNull(resp); + assertTrue(resp.id().startsWith("caresp_")); + assertEquals(java.util.Optional.of(ResponseStatus.COMPLETED), resp.status()); + assertEquals(1, resp.output().size()); + assertTrue(resp.output().get(0).isMessage()); + // No conversation id should be invented when the client didn't supply one + // (Foundry storage rejects responses that reference an unknown conversation). + assertFalse(resp.conversation().isPresent(), + "Conversation should be absent when the request did not specify one"); + } + + @Test + @DisplayName("getModelName extracts model from request or uses default") + void getModelNameExtractsOrDefaults() { + AgentServerCreateResponse request = createRequest("Hello"); + String model = ResponseBuilder.getModelName(request); + // Should be either "test-model" or the default "gpt-4o" + assertNotNull(model); + assertFalse(model.isEmpty()); + } + } + + // ══════════════════════════════════════════════════════════════ + // HealthApi defaults + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("HealthApi defaults") + class HealthApiTests { + + @Test + @DisplayName("Default HealthApi returns true for readiness and liveness") + void defaultHealthChecks() { + HealthApi health = new HealthApi() { + }; + assertTrue(health.isReady()); + assertTrue(health.isAlive()); + } + + @Test + @DisplayName("Custom HealthApi can override readiness") + void customHealthCheck() { + HealthApi health = new HealthApi() { + @Override + public boolean isReady() { + return false; + } + }; + assertFalse(health.isReady()); + assertTrue(health.isAlive()); // Default still true + } + } + + // ══════════════════════════════════════════════════════════════ + // ApiException + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("ApiException") + class ApiExceptionTests { + + @Test + @DisplayName("ApiException carries status code and structured error body") + void exceptionCarriesInfo() { + ApiException ex = new ApiException(404, ApiError.invalidRequest("Not found")); + assertEquals(404, ex.getStatusCode()); + assertEquals("Not found", ex.getError().message()); + assertEquals("invalid_request_error", ex.getError().type()); + assertEquals("invalid_request_error", ex.getError().code()); + assertTrue(ex.getMessage().contains("404")); + } + + @Test + @DisplayName("ApiException with serverError builds a 500-style body") + void serverErrorBody() { + ApiException ex = new ApiException(500, ApiError.serverError("boom")); + assertEquals(500, ex.getStatusCode()); + assertEquals("server_error", ex.getError().type()); + assertEquals("server_error", ex.getError().code()); + assertEquals("boom", ex.getError().message()); + } + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/IdGeneratorTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/IdGeneratorTest.java new file mode 100644 index 000000000000..a76044e0ccab --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/IdGeneratorTest.java @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.microsoft.agentserver.api.implementation.IdGenerator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link IdGenerator} ID format, focusing on the Responses + * protocol prefix table. + */ +class IdGeneratorTest { + + @Test + @DisplayName("generateResponseId uses the caresp_ prefix") + void responseIdUsesCarespPrefix() { + IdGenerator idGen = new IdGenerator(null); + String id = idGen.generateResponseId(); + + assertTrue(id.startsWith("caresp_"), + "Response ID must use the 'caresp' prefix, was: " + id); + // body total = 18 (partition key) + 32 (entropy) = 50 chars after `caresp_`. + assertEquals(50, id.substring("caresp_".length()).length(), + "Response ID body must be 50 chars (18 partition + 32 entropy)"); + } + + @Test + @DisplayName("generateResponseId reuses the supplied partition key for co-location") + void responseIdReusesPartitionKey() { + IdGenerator idGen = new IdGenerator("AAAAAAAAAAAAAAAAAA"); + String id = idGen.generateResponseId(); + + assertEquals("AAAAAAAAAAAAAAAAAA", IdGenerator.extractPartitionKey(id)); + } + + @Test + @DisplayName("Two generated response IDs differ in entropy") + void responseIdsAreUnique() { + IdGenerator idGen = new IdGenerator(null); + assertNotEquals(idGen.generateResponseId(), idGen.generateResponseId()); + } +} + diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/InMemoryResponseProviderIntegrationTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/InMemoryResponseProviderIntegrationTest.java new file mode 100644 index 000000000000..2795c2072232 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/InMemoryResponseProviderIntegrationTest.java @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.microsoft.agentserver.api.implementation.InMemoryResponseProvider; +import com.openai.core.JsonMissing; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseItem; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseOutputText; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ToolChoiceOptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for InMemoryResponseProvider: exercises the full state management + * lifecycle including conversation chaining, history resolution, and pagination. + */ +class InMemoryResponseProviderIntegrationTest { + + private InMemoryResponseProvider provider; + + private static Response buildTestResponse(String responseId, String conversationId, String previousResponseId, String messageText) { + String msgId = "msg_" + responseId.substring(5); + ResponseOutputMessage message = ResponseOutputMessage.builder() + .id(msgId) + .status(ResponseOutputMessage.Status.COMPLETED) + .addContent(ResponseOutputMessage.Content.ofOutputText( + ResponseOutputText.builder() + .text(messageText) + .annotations(List.of()) + .build())) + .build(); + + Response.Builder builder = Response.builder() + .id(responseId) + .createdAt(System.currentTimeMillis() / 1000.0) + .addOutput(ResponseOutputItem.ofMessage(message)) + .model("test") + .parallelToolCalls(false) + .tools(List.of()) + .status(ResponseStatus.COMPLETED) + .error(JsonMissing.of()) + .incompleteDetails(JsonMissing.of()) + .instructions(JsonMissing.of()) + .metadata(JsonMissing.of()) + .temperature(JsonMissing.of()) + .topP(JsonMissing.of()) + .toolChoice(ToolChoiceOptions.AUTO); + + if (conversationId != null) { + builder.conversation(Response.Conversation.builder().id(conversationId).build()); + } + if (previousResponseId != null) { + builder.previousResponseId(previousResponseId); + } + return builder.build(); + } + + @BeforeEach + void setUp() { + provider = new InMemoryResponseProvider(); + } + + // ══════════════════════════════════════════════════════════════ + // Basic CRUD operations + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Basic CRUD operations") + class BasicCrud { + + @Test + @DisplayName("Save and retrieve a response") + void saveAndGet() { + Response resp = buildTestResponse("resp_001", null, null, "Hello"); + provider.saveResponseAsync("resp_001", resp, null, null).join(); + + Optional fetched = provider.getResponseAsync("resp_001").join(); + assertTrue(fetched.isPresent()); + assertEquals("resp_001", fetched.get().id()); + assertEquals(1, fetched.get().output().size()); + } + + @Test + @DisplayName("Get non-existent response returns empty") + void getNonExistent() { + Optional result = provider.getResponseAsync("resp_missing").join(); + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("Delete removes response and its items") + void deleteRemovesResponseAndItems() { + Response resp = buildTestResponse("resp_del", null, null, "Delete me"); + provider.saveResponseAsync("resp_del", resp, null, null).join(); + + provider.deleteResponseAsync("resp_del").join(); + + assertTrue(provider.getResponseAsync("resp_del").join().isEmpty()); + } + + @Test + @DisplayName("Save multiple responses independently") + void saveMultiple() { + Response r1 = buildTestResponse("resp_a", null, null, "First"); + Response r2 = buildTestResponse("resp_b", null, null, "Second"); + + provider.saveResponseAsync("resp_a", r1, null, null).join(); + provider.saveResponseAsync("resp_b", r2, null, null).join(); + + assertTrue(provider.getResponseAsync("resp_a").join().isPresent()); + assertTrue(provider.getResponseAsync("resp_b").join().isPresent()); + } + + @Test + @DisplayName("Delete non-existent response is a no-op") + void deleteNonExistent() { + assertDoesNotThrow(() -> provider.deleteResponseAsync("resp_nope").join()); + } + } + + // ══════════════════════════════════════════════════════════════ + // Item storage and retrieval + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Item storage and retrieval") + class ItemStorage { + + @Test + @DisplayName("Saved response items are retrievable by ID") + void outputItemsRetrievable() { + Response resp = buildTestResponse("resp_items", null, null, "Test"); + provider.saveResponseAsync("resp_items", resp, null, null).join(); + + // The provider stores output items by their IDs + // The message ID should be "msg_items" based on our builder + List items = provider.getItemsAsync(List.of("msg_items")).join(); + assertEquals(1, items.size()); + assertNotNull(items.get(0)); + } + + @Test + @DisplayName("Missing item IDs return null entries") + void missingItemsReturnNull() { + List items = provider.getItemsAsync( + List.of("missing_1", "missing_2")).join(); + assertEquals(2, items.size()); + assertNull(items.get(0)); + assertNull(items.get(1)); + } + + @Test + @DisplayName("Save input items then output items preserves ordering") + void inputItemsBeforeOutputItems() { + // First save input items + ResponseOutputMessage inputMsg = ResponseOutputMessage.builder() + .id("input_msg_1") + .status(ResponseOutputMessage.Status.COMPLETED) + .addContent(ResponseOutputMessage.Content.ofOutputText( + ResponseOutputText.builder().text("user input").annotations(List.of()).build())) + .build(); + ResponseItem inputItem = ResponseItem.ofResponseOutputMessage(inputMsg); + + provider.saveInputItemsAsync("resp_order", + List.of(inputItem)).join(); + + // Then save the response (appends output items) + Response resp = buildTestResponse("resp_order", null, null, "response text"); + provider.saveResponseAsync("resp_order", resp, null, null).join(); + + // Verify both items are retrievable + assertNotNull(provider.getItemsAsync(List.of("input_msg_1")).join().get(0)); + } + } + + // ══════════════════════════════════════════════════════════════ + // Conversation history and chaining + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Conversation history and chaining") + class ConversationHistory { + + @Test + @DisplayName("Chain of responses via previousResponseId builds history") + void responseChainHistory() { + Response r1 = buildTestResponse("resp_chain_1", "conv_1", null, "First"); + Response r2 = buildTestResponse("resp_chain_2", "conv_1", "resp_chain_1", "Second"); + Response r3 = buildTestResponse("resp_chain_3", "conv_1", "resp_chain_2", "Third"); + + provider.saveResponseAsync("resp_chain_1", r1, null, "conv_1").join(); + provider.saveResponseAsync("resp_chain_2", r2, "resp_chain_1", "conv_1").join(); + provider.saveResponseAsync("resp_chain_3", r3, "resp_chain_2", "conv_1").join(); + + // Get history from the perspective of resp_chain_3 + List history = provider.getHistoryItemIdsAsync("resp_chain_2", null, 100).join(); + + // Should include items from resp_chain_1 and resp_chain_2 + assertFalse(history.isEmpty()); + } + + @Test + @DisplayName("Conversation-based history across multiple responses") + void conversationBasedHistory() { + Response r1 = buildTestResponse("resp_conv_a", "conv_abc", null, "First"); + Response r2 = buildTestResponse("resp_conv_b", "conv_abc", null, "Second"); + + provider.saveResponseAsync("resp_conv_a", r1, null, "conv_abc").join(); + provider.saveResponseAsync("resp_conv_b", r2, null, "conv_abc").join(); + + List history = provider.getHistoryItemIdsAsync(null, "conv_abc", 100).join(); + assertFalse(history.isEmpty()); + } + + @Test + @DisplayName("History with limit caps returned items") + void historyWithLimit() { + // Create 5 responses in a conversation + for (int i = 0; i < 5; i++) { + String prevId = i > 0 ? "resp_lim_" + (i - 1) : null; + Response r = buildTestResponse("resp_lim_" + i, "conv_lim", prevId, "Msg " + i); + provider.saveResponseAsync("resp_lim_" + i, r, prevId, "conv_lim").join(); + } + + // Get history with limit of 2 + List limited = provider.getHistoryItemIdsAsync(null, "conv_lim", 2).join(); + assertTrue(limited.size() <= 2, + "History should be capped at limit but got " + limited.size()); + } + + @Test + @DisplayName("Empty history when no previous response or conversation") + void emptyHistory() { + List history = provider.getHistoryItemIdsAsync(null, null, 100).join(); + assertTrue(history.isEmpty()); + } + + @Test + @DisplayName("Delete response removes it from conversation tracking") + void deleteRemovesFromConversation() { + Response r = buildTestResponse("resp_conv_del", "conv_del", null, "Delete me"); + provider.saveResponseAsync("resp_conv_del", r, null, "conv_del").join(); + + provider.deleteResponseAsync("resp_conv_del").join(); + + List history = provider.getHistoryItemIdsAsync(null, "conv_del", 100).join(); + assertTrue(history.isEmpty()); + } + } + + // ══════════════════════════════════════════════════════════════ + // Thread safety + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Concurrent access") + class ConcurrentAccess { + + @Test + @DisplayName("Concurrent saves and reads do not corrupt state") + void concurrentSavesAndReads() throws Exception { + int count = 50; + CompletableFuture[] futures = new CompletableFuture[count * 2]; + + for (int i = 0; i < count; i++) { + String id = "resp_concurrent_" + i; + Response r = buildTestResponse(id, null, null, "Msg " + i); + futures[i] = CompletableFuture.runAsync(() -> + provider.saveResponseAsync(id, r, null, null).join()); + } + + for (int i = 0; i < count; i++) { + String id = "resp_concurrent_" + i; + futures[count + i] = CompletableFuture.runAsync(() -> + provider.getResponseAsync(id).join()); + } + + CompletableFuture.allOf(futures).join(); + + // Verify all responses were saved + for (int i = 0; i < count; i++) { + assertTrue(provider.getResponseAsync("resp_concurrent_" + i).join().isPresent(), + "Response resp_concurrent_" + i + " should be present"); + } + } + } + + // ══════════════════════════════════════════════════════════════ + // Provider factory + // ══════════════════════════════════════════════════════════════ + + @Test + @DisplayName("ResponsesProvider.inMemory() returns a working provider") + void inMemoryFactory() { + ResponsesProvider p = ResponsesProvider.inMemory(); + assertNotNull(p); + assertInstanceOf(InMemoryResponseProvider.class, p); + + Response r = buildTestResponse("resp_factory", null, null, "Factory"); + p.saveResponseAsync("resp_factory", r, null, null).join(); + assertTrue(p.getResponseAsync("resp_factory").join().isPresent()); + } +} + + + diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponseContextIntegrationTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponseContextIntegrationTest.java new file mode 100644 index 000000000000..68dd44a98601 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponseContextIntegrationTest.java @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.openai.core.JsonMissing; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseItem; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseOutputText; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ToolChoiceOptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for ResponseContext: input item resolution, history fetching, + * and caching behavior with a real InMemoryResponseProvider. + */ +class ResponseContextIntegrationTest { + + private ResponsesProvider provider; + + @BeforeEach + void setUp() { + provider = ResponsesProvider.inMemory(); + } + + private static Response buildResponse(String id, String convId, String prevId, String text) { + ResponseOutputMessage message = ResponseOutputMessage.builder() + .id("msg_" + id.substring(5)) + .status(ResponseOutputMessage.Status.COMPLETED) + .addContent(ResponseOutputMessage.Content.ofOutputText( + ResponseOutputText.builder().text(text).annotations(List.of()).build())) + .build(); + + Response.Builder b = Response.builder() + .id(id) + .createdAt(System.currentTimeMillis() / 1000.0) + .addOutput(ResponseOutputItem.ofMessage(message)) + .model("test") + .parallelToolCalls(false) + .tools(List.of()) + .status(ResponseStatus.COMPLETED) + .error(JsonMissing.of()) + .incompleteDetails(JsonMissing.of()) + .instructions(JsonMissing.of()) + .metadata(JsonMissing.of()) + .temperature(JsonMissing.of()) + .topP(JsonMissing.of()) + .toolChoice(ToolChoiceOptions.AUTO); + if (convId != null) b.conversation(Response.Conversation.builder().id(convId).build()); + if (prevId != null) b.previousResponseId(prevId); + return b.build(); + } + + @Nested + @DisplayName("Input item resolution") + class InputItemResolution { + + @Test + @DisplayName("Text input returns empty input items list") + void textInputReturnsEmpty() { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("Just a string") + .model("test") + .build() + ._body(); + + ResponseContext ctx = ResponseContext.builder() + .responseId("resp_AAAAAAAAAAAAAAAAAA" + "B".repeat(32)) + .provider(provider) + .request(body) + .build(); + + List items = ctx.getInputItemsAsync().join(); + assertTrue(items.isEmpty()); + } + + @Test + @DisplayName("Structured input items are converted to output items") + void structuredInputConverted() { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input(ResponseCreateParams.Input.ofResponse(List.of( + com.openai.models.responses.ResponseInputItem.ofEasyInputMessage( + com.openai.models.responses.EasyInputMessage.builder() + .role(com.openai.models.responses.EasyInputMessage.Role.USER) + .content("What is 2+2?") + .build() + ) + ))) + .model("test") + .build() + ._body(); + + ResponseContext ctx = ResponseContext.builder() + .responseId("resp_AAAAAAAAAAAAAAAAAA" + "B".repeat(32)) + .provider(provider) + .request(body) + .build(); + + List items = ctx.getInputItemsAsync().join(); + assertFalse(items.isEmpty()); + assertTrue(items.get(0).isMessage()); + } + + @Test + @DisplayName("Input items are cached after first resolution") + void inputItemsCached() { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("cached test") + .model("test") + .build() + ._body(); + + ResponseContext ctx = ResponseContext.builder() + .responseId("resp_AAAAAAAAAAAAAAAAAA" + "B".repeat(32)) + .provider(provider) + .request(body) + .build(); + + CompletableFuture> first = ctx.getInputItemsAsync(); + CompletableFuture> second = ctx.getInputItemsAsync(); + + // Should return the same future instance (cached) + assertSame(first, second); + } + } + + @Nested + @DisplayName("History resolution") + class HistoryResolution { + + @Test + @DisplayName("No previous response or conversation returns empty history") + void noHistoryContext() { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("no history") + .model("test") + .build() + ._body(); + + ResponseContext ctx = ResponseContext.builder() + .responseId("resp_AAAAAAAAAAAAAAAAAA" + "B".repeat(32)) + .provider(provider) + .request(body) + .build(); + + List history = ctx.getHistoryAsync().join(); + assertTrue(history.isEmpty()); + } + + @Test + @DisplayName("History from previous response chain") + void historyFromPreviousResponse() { + // Save a previous response + Response prev = buildResponse("resp_prev_001", "conv_hist", null, "Previous message"); + provider.saveResponseAsync("resp_prev_001", prev, null, "conv_hist").join(); + + // Build context that references the previous response + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("follow up") + .model("test") + .previousResponseId("resp_prev_001") + .build() + ._body(); + + ResponseContext ctx = ResponseContext.builder() + .responseId("resp_AAAAAAAAAAAAAAAAAA" + "B".repeat(32)) + .provider(provider) + .request(body) + .build(); + + List history = ctx.getHistoryAsync().join(); + assertFalse(history.isEmpty(), "Should have history from previous response"); + } + + @Test + @DisplayName("History is cached after first resolution") + void historyCached() { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("cached history") + .model("test") + .build() + ._body(); + + ResponseContext ctx = ResponseContext.builder() + .responseId("resp_AAAAAAAAAAAAAAAAAA" + "B".repeat(32)) + .provider(provider) + .request(body) + .build(); + + CompletableFuture> first = ctx.getHistoryAsync(); + CompletableFuture> second = ctx.getHistoryAsync(); + assertSame(first, second); + } + } + + @Nested + @DisplayName("Context builder validation") + class BuilderValidation { + + @Test + @DisplayName("Builder requires responseId") + void requiresResponseId() { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("test").model("test").build()._body(); + + assertThrows(NullPointerException.class, () -> + ResponseContext.builder() + .provider(provider) + .request(body) + .build()); + } + + @Test + @DisplayName("Builder requires provider") + void requiresProvider() { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("test").model("test").build()._body(); + + assertThrows(NullPointerException.class, () -> + ResponseContext.builder() + .responseId("resp_test") + .request(body) + .build()); + } + + @Test + @DisplayName("Builder requires request") + void requiresRequest() { + assertThrows(NullPointerException.class, () -> + ResponseContext.builder() + .responseId("resp_test") + .provider(provider) + .build()); + } + + @Test + @DisplayName("Shutdown flag is initially false") + void shutdownInitiallyFalse() { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("test").model("test").build()._body(); + + AgentServerResponseContext ctx = (AgentServerResponseContext) ResponseContext.builder() + .responseId("resp_AAAAAAAAAAAAAAAAAA" + "B".repeat(32)) + .provider(provider) + .request(body) + .build(); + + assertFalse(ctx.isShutdownRequested()); + ctx.setShutdownRequested(true); + assertTrue(ctx.isShutdownRequested()); + } + + @Test + @DisplayName("rawBody is null when not provided") + void rawBodyNullByDefault() { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("test").model("test").build()._body(); + + ResponseContext ctx = ResponseContext.builder() + .responseId("resp_AAAAAAAAAAAAAAAAAA" + "B".repeat(32)) + .provider(provider) + .request(body) + .build(); + + assertNull(ctx.getRawBody()); + } + } +} + + + + diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponseEventStreamIntegrationTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponseEventStreamIntegrationTest.java new file mode 100644 index 000000000000..fcadbc2ef5f9 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponseEventStreamIntegrationTest.java @@ -0,0 +1,508 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseError; +import com.openai.models.responses.ResponseFunctionToolCall; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ResponseUsage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for ResponseEventStream: verifies end-to-end event production, + * subscription/replay, and final Response snapshot construction across all builder types. + */ +class ResponseEventStreamIntegrationTest { + + private ResponseContext context; + private AgentServerCreateResponse request; + + @BeforeEach + void setUp() { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("test input") + .model("test-model") + .build() + ._body(); + request = new AgentServerCreateResponse(null, body); + + context = ResponseContext.builder() + .responseId("resp_AAAAAAAAAAAAAAAAAA" + "B".repeat(32)) + .provider(ResponsesProvider.inMemory()) + .request(body) + .build(); + } + + // ══════════════════════════════════════════════════════════════ + // Full message streaming lifecycle + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Message streaming lifecycle") + class MessageStreaming { + + @Test + @DisplayName("Complete message lifecycle produces correct event sequence") + void completeMessageLifecycle() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg + .emitAdded() + .addTextPart(text -> text + .emitAdded() + .emitDelta("Hello, ") + .emitDelta("how are you?") + .emitDone("Hello, how are you?")) + .emitDone()) + .emitCompleted(); + + List names = stream.getEvents().stream() + .map(ResponseEvent::eventName) + .toList(); + + assertEquals(List.of( + "response.created", + "response.in_progress", + "response.output_item.added", + "response.content_part.added", + "response.output_text.delta", + "response.output_text.delta", + "response.output_text.done", + "response.content_part.done", + "response.output_item.done", + "response.completed" + ), names); + } + + @Test + @DisplayName("outputItemMessage convenience method produces same event sequence") + void outputItemMessageConvenience() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg.outputItemMessage("Hello!")) + .emitCompleted(); + + List names = stream.getEvents().stream() + .map(ResponseEvent::eventName) + .toList(); + + assertTrue(names.contains("response.output_item.added")); + assertTrue(names.contains("response.output_text.delta")); + assertTrue(names.contains("response.output_text.done")); + assertTrue(names.contains("response.content_part.done")); + assertTrue(names.contains("response.output_item.done")); + } + + @Test + @DisplayName("Multiple text parts in one message") + void multipleTextParts() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg + .emitAdded() + .addTextPart(text -> text + .emitAdded() + .emitDelta("Part 1") + .emitDone("Part 1")) + .addTextPart(text -> text + .emitAdded() + .emitDelta("Part 2") + .emitDone("Part 2")) + .emitDone()) + .emitCompleted(); + + long contentPartAddedCount = stream.getEvents().stream() + .filter(e -> "response.content_part.added".equals(e.eventName())) + .count(); + assertEquals(2, contentPartAddedCount); + } + + @Test + @DisplayName("Final snapshot contains accumulated output items") + void snapshotContainsOutputItems() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg + .outputItemMessage("First message")) + .addOutputMessage(msg -> msg + .outputItemMessage("Second message")) + .emitCompleted(); + + Response snapshot = stream.getResponse(); + assertEquals(java.util.Optional.of(ResponseStatus.COMPLETED), snapshot.status()); + assertEquals(2, snapshot.output().size()); + + // Verify message content + ResponseOutputItem first = snapshot.output().get(0); + assertTrue(first.isMessage()); + ResponseOutputMessage firstMsg = first.asMessage(); + assertEquals(ResponseOutputMessage.Status.COMPLETED, firstMsg.status()); + } + + @Test + @DisplayName("Auto-emits output_text.done when not explicitly called") + void autoEmitsTextDone() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg + .emitAdded() + .addTextPart(text -> text + .emitAdded() + .emitDelta("auto done")) + .emitDone()) + .emitCompleted(); + + // Should still have output_text.done auto-generated + assertTrue(stream.getEvents().stream() + .anyMatch(e -> "response.output_text.done".equals(e.eventName()))); + } + } + + // ══════════════════════════════════════════════════════════════ + // Function call streaming lifecycle + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Function call streaming lifecycle") + class FunctionCallStreaming { + + @Test + @DisplayName("Complete function call lifecycle") + void completeFunctionCallLifecycle() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + + stream.emitCreated() + .emitInProgress() + .addOutputFunctionCall(func -> func + .emitAdded("get_weather", "call_123") + .emitArgumentsDelta("{\"loc\":") + .emitArgumentsDelta("\"Seattle\"}") + .emitArgumentsDone("get_weather", "{\"loc\":\"Seattle\"}") + .emitDone()) + .emitCompleted(); + + List names = stream.getEvents().stream() + .map(ResponseEvent::eventName) + .toList(); + + assertTrue(names.contains("response.output_item.added")); + assertTrue(names.contains("response.function_call_arguments.delta")); + assertTrue(names.contains("response.function_call_arguments.done")); + assertTrue(names.contains("response.output_item.done")); + + // Verify snapshot + Response snapshot = stream.getResponse(); + assertEquals(1, snapshot.output().size()); + assertTrue(snapshot.output().get(0).isFunctionCall()); + ResponseFunctionToolCall call = snapshot.output().get(0).asFunctionCall(); + assertEquals("get_weather", call.name()); + assertEquals("{\"loc\":\"Seattle\"}", call.arguments()); + } + + @Test + @DisplayName("Mixed message and function call outputs") + void mixedOutputs() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg.outputItemMessage("Checking weather...")) + .addOutputFunctionCall(func -> func + .emitAdded("get_weather", "call_1") + .emitArgumentsDelta("{}") + .emitArgumentsDone("get_weather", "{}") + .emitDone()) + .emitCompleted(); + + Response snapshot = stream.getResponse(); + assertEquals(2, snapshot.output().size()); + assertTrue(snapshot.output().get(0).isMessage()); + assertTrue(snapshot.output().get(1).isFunctionCall()); + } + } + + // ══════════════════════════════════════════════════════════════ + // Terminal event semantics + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Terminal event semantics") + class TerminalEvents { + + @Test + @DisplayName("emitCompleted is a terminal event") + void completedIsTerminal() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + stream.emitCreated().emitCompleted(); + + assertThrows(IllegalStateException.class, stream::emitCompleted); + assertThrows(IllegalStateException.class, stream::emitFailed); + assertThrows(IllegalStateException.class, stream::emitIncomplete); + } + + @Test + @DisplayName("emitFailed is a terminal event") + void failedIsTerminal() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + stream.emitCreated().emitFailed(); + + Response snapshot = stream.getResponse(); + assertEquals(java.util.Optional.of(ResponseStatus.FAILED), snapshot.status()); + assertThrows(IllegalStateException.class, stream::emitCompleted); + } + + @Test + @DisplayName("emitFailed with custom error code and message") + void failedWithCustomError() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + stream.emitCreated() + .emitFailed(ResponseError.Code.SERVER_ERROR, "Too many requests"); + + Response snapshot = stream.getResponse(); + assertEquals(java.util.Optional.of(ResponseStatus.FAILED), snapshot.status()); + assertTrue(snapshot.error().isPresent()); + assertEquals(ResponseError.Code.SERVER_ERROR, snapshot.error().get().code()); + } + + @Test + @DisplayName("emitIncomplete is a terminal event") + void incompleteIsTerminal() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + stream.emitCreated() + .emitIncomplete(Response.IncompleteDetails.Reason.MAX_OUTPUT_TOKENS); + + Response snapshot = stream.getResponse(); + assertEquals(java.util.Optional.of(ResponseStatus.INCOMPLETE), snapshot.status()); + assertTrue(snapshot.incompleteDetails().isPresent()); + } + + @Test + @DisplayName("emitCompleted with custom usage data") + void completedWithUsage() { + ResponseUsage usage = ResponseUsage.builder() + .inputTokens(100) + .outputTokens(50) + .totalTokens(150) + .inputTokensDetails(ResponseUsage.InputTokensDetails.builder().cachedTokens(10).build()) + .outputTokensDetails(ResponseUsage.OutputTokensDetails.builder().reasoningTokens(5).build()) + .build(); + + ResponseEventStream stream = ResponseEventStream.create(context, request); + stream.emitCreated().emitCompleted(usage); + + Response snapshot = stream.getResponse(); + assertEquals(java.util.Optional.of(ResponseStatus.COMPLETED), snapshot.status()); + assertTrue(snapshot.usage().isPresent()); + assertEquals(150, snapshot.usage().get().totalTokens()); + } + } + + // ══════════════════════════════════════════════════════════════ + // Subscription and reactive delivery + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Subscription and reactive delivery") + class SubscriptionTests { + + @Test + @DisplayName("Subscribe receives all events including replayed ones") + void subscribeReceivesAllEvents() throws InterruptedException { + ResponseEventStream stream = ResponseEventStream.create(context, request); + + // Emit some events before subscribing + stream.emitCreated().emitInProgress(); + + CopyOnWriteArrayList received = new CopyOnWriteArrayList<>(); + AtomicBoolean completed = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + + // Now subscribe — should replay created + in_progress, then receive rest + stream.subscribe( + event -> received.add(event.eventName()), + failure -> fail("Unexpected failure: " + failure), + () -> { + completed.set(true); + latch.countDown(); + } + ); + + // Now emit more events + stream.addOutputMessage(msg -> msg.outputItemMessage("Hello")) + .emitCompleted(); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + assertTrue(completed.get()); + + // First two events should be replayed + assertEquals("response.created", received.get(0)); + assertEquals("response.in_progress", received.get(1)); + assertTrue(received.contains("response.completed")); + } + + @Test + @DisplayName("Subscribing to already-completed stream delivers all events and completes") + void subscribeToCompletedStream() throws InterruptedException { + ResponseEventStream stream = ResponseEventStream.create(context, request); + stream.emitCreated().emitCompleted(); + + CopyOnWriteArrayList received = new CopyOnWriteArrayList<>(); + AtomicBoolean completed = new AtomicBoolean(false); + + stream.subscribe( + event -> received.add(event.eventName()), + failure -> fail("Unexpected failure"), + () -> completed.set(true) + ); + + // Should have replayed events and called onComplete + assertTrue(completed.get()); + assertFalse(received.isEmpty()); + } + + @Test + @DisplayName("Async producer with awaitSubscription") + void asyncProducerWithAwaitSubscription() throws Exception { + ResponseEventStream stream = ResponseEventStream.create(context, request); + CopyOnWriteArrayList received = new CopyOnWriteArrayList<>(); + CountDownLatch doneLatch = new CountDownLatch(1); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + executor.submit(() -> { + try { + stream.awaitSubscription(); + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg.outputItemMessage("Async!")) + .emitCompleted(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // Small delay to ensure the executor thread is waiting + Thread.sleep(50); + + stream.subscribe( + event -> received.add(event.eventName()), + failure -> fail("Unexpected failure"), + () -> doneLatch.countDown() + ); + + assertTrue(doneLatch.await(5, TimeUnit.SECONDS)); + assertTrue(received.contains("response.created")); + assertTrue(received.contains("response.completed")); + + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + // ══════════════════════════════════════════════════════════════ + // Sequence numbers and response ID propagation + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Sequence numbers and IDs") + class SequenceAndIds { + + @Test + @DisplayName("Sequence numbers are monotonically increasing") + void sequenceNumbersIncrease() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg.outputItemMessage("Test")) + .emitCompleted(); + + // Verify all events have the stream's response ID + Response snapshot = stream.getResponse(); + assertNotNull(snapshot.id()); + assertTrue(snapshot.id().startsWith("resp_")); + } + + @Test + @DisplayName("Output item IDs are unique across multiple outputs") + void outputItemIdsAreUnique() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg.outputItemMessage("First")) + .addOutputMessage(msg -> msg.outputItemMessage("Second")) + .addOutputFunctionCall(func -> func + .emitAdded("tool", "call_1") + .emitArgumentsDone("tool", "{}") + .emitDone()) + .emitCompleted(); + + Response snapshot = stream.getResponse(); + List ids = new ArrayList<>(); + for (ResponseOutputItem item : snapshot.output()) { + if (item.isMessage()) ids.add(item.asMessage().id()); + if (item.isFunctionCall()) ids.add(item.asFunctionCall().id().orElse("")); + } + + assertEquals(ids.size(), ids.stream().distinct().count(), + "All output item IDs should be unique"); + } + } + + // ══════════════════════════════════════════════════════════════ + // Queued lifecycle variant + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Queued lifecycle") + class QueuedLifecycle { + + @Test + @DisplayName("emitQueued → emitCreated → emitInProgress → emitCompleted") + void queuedFullLifecycle() { + ResponseEventStream stream = ResponseEventStream.create(context, request); + stream.emitQueued() + .emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg.outputItemMessage("Delayed")) + .emitCompleted(); + + List names = stream.getEvents().stream() + .map(ResponseEvent::eventName) + .toList(); + + assertEquals("response.queued", names.get(0)); + assertEquals("response.created", names.get(1)); + assertTrue(names.contains("response.completed")); + } + } +} + + diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponseIdOverrideIntegrationTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponseIdOverrideIntegrationTest.java new file mode 100644 index 000000000000..a0f8a22bde46 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponseIdOverrideIntegrationTest.java @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseOutputText; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the {@code x-agent-response-id} request-header override. + */ +class ResponseIdOverrideIntegrationTest { + + /** + * Builds a well-formed response ID (caresp_ + 50 chars) from a label. + */ + private static String vid(String label) { + String body = (label.replaceAll("[^A-Za-z0-9]", "") + "A".repeat(50)).substring(0, 50); + return "caresp_" + body; + } + + private ResponsesApi api; + + @BeforeEach + void setUp() { + api = ResponsesApi.builder() + .responseHandler(new ResponseHandler() { + @Override + public CreateResponse createResponse(ResponseContext ctx, AgentServerCreateResponse request) { + ResponseOutputText text = ResponseOutputText.builder() + .text("ok").annotations(List.of()).build(); + Response resp = ResponseBuilder.convertOutputToResponse(request, text); + return new CreateResponse(null, resp); + } + }) + .provider(ResponsesProvider.inMemory()) + .build(); + } + + private static AgentServerCreateResponse request() { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("hi").model("test-model").build()._body(); + return new AgentServerCreateResponse(null, body); + } + + private static RequestMetadata withResponseIdHeader(String value) { + return new RequestMetadata( + IsolationContext.EMPTY, + Collections.emptyMap(), + Collections.emptyMap(), + null, + value); + } + + @Test + @DisplayName("Non-empty x-agent-response-id header is used as the response ID") + void overrideHeaderWins() throws ApiException { + String custom = vid("custom"); + CreateResponse created = api.createResponse(request(), withResponseIdHeader(custom)); + assertEquals(custom, created.response().id(), + "the returned response.id must match the x-agent-response-id header"); + + // And the persisted state is keyed by that override too. + com.openai.models.responses.Response persisted = api.getResponse(custom, List.of()); + assertEquals(custom, persisted.id()); + } + + @Test + @DisplayName("Empty x-agent-response-id header falls back to generation ( negative)") + void emptyOverrideFallsBackToGeneration() throws ApiException { + CreateResponse a = api.createResponse(request(), withResponseIdHeader("")); + CreateResponse b = api.createResponse(request(), withResponseIdHeader(null)); + assertTrue(a.response().id().startsWith("caresp_")); + assertTrue(b.response().id().startsWith("caresp_")); + assertNotEquals(a.response().id(), b.response().id()); + } + + @Test + @DisplayName("Header override beats body metadata.response_id ( precedence)") + void headerBeatsBodyMetadata() throws ApiException { + String headerId = vid("hdr"); + String bodyId = vid("body"); + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("hi").model("test-model") + .metadata(ResponseCreateParams.Metadata.builder() + .putAdditionalProperty("response_id", com.openai.core.JsonValue.from(bodyId)) + .build()) + .build()._body(); + AgentServerCreateResponse req = new AgentServerCreateResponse(null, body); + + CreateResponse created = api.createResponse(req, withResponseIdHeader(headerId)); + assertEquals(headerId, created.response().id(), + "header override must beat body metadata.response_id"); + // Persisted state confirms it. + assertEquals(headerId, api.getResponse(headerId, List.of()).id()); + } +} + + diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponsesApiIntegrationTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponsesApiIntegrationTest.java new file mode 100644 index 000000000000..5f95b5fe1715 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/ResponsesApiIntegrationTest.java @@ -0,0 +1,367 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseItem; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseOutputText; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.inputitems.ResponseItemList; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for the full ResponsesApi lifecycle: create → get → list → delete. + * Uses a real InMemoryResponseProvider and a test handler to exercise the complete + * orchestration pipeline without mocking. + */ +class ResponsesApiIntegrationTest { + + private ResponsesProvider provider; + private ResponsesApi api; + + @BeforeEach + void setUp() { + provider = ResponsesProvider.inMemory(); + } + + // ── Helper: builds a AgentServerCreateResponse with text input ── + + private static AgentServerCreateResponse createRequest(String text) { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input(text) + .model("test-model") + .build() + ._body(); + return new AgentServerCreateResponse(null, body); + } + + private static AgentServerCreateResponse createRequestWithPreviousResponse(String text, String previousResponseId) { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input(text) + .model("test-model") + .previousResponseId(previousResponseId) + .build() + ._body(); + return new AgentServerCreateResponse(null, body); + } + + // ══════════════════════════════════════════════════════════════ + // Synchronous (non-streaming) response lifecycle + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Synchronous response lifecycle") + class SyncResponseLifecycle { + + @BeforeEach + void setUp() { + // Handler that returns a simple text response + ResponseHandler handler = new ResponseHandler() { + @Override + public CreateResponse createResponse(ResponseContext ctx, AgentServerCreateResponse request) { + ResponseOutputText outputText = ResponseOutputText.builder() + .text("Hello from test handler!") + .annotations(List.of()) + .build(); + Response resp = ResponseBuilder.convertOutputToResponse(request, outputText); + return new CreateResponse(null, resp); + } + }; + api = ResponsesApi.builder() + .responseHandler(handler) + .provider(provider) + .build(); + } + + @Test + @DisplayName("Create → Get → Delete full lifecycle") + void createGetDeleteLifecycle() throws ApiException { + // Create + AgentServerCreateResponse request = createRequest("Hello"); + CreateResponse created = api.createResponse(request); + + assertNotNull(created); + assertNotNull(created.response()); + assertEquals(java.util.Optional.of(ResponseStatus.COMPLETED), created.response().status()); + assertTrue(created.response().id().startsWith("caresp_")); + + String responseId = created.response().id(); + + // Get + Response fetched = api.getResponse(responseId, List.of()); + assertNotNull(fetched); + assertEquals(responseId, fetched.id()); + assertEquals(java.util.Optional.of(ResponseStatus.COMPLETED), fetched.status()); + + // Verify output content + assertFalse(fetched.output().isEmpty()); + ResponseOutputItem firstOutput = fetched.output().get(0); + assertTrue(firstOutput.isMessage()); + ResponseOutputMessage message = firstOutput.asMessage(); + assertFalse(message.content().isEmpty()); + assertTrue(message.content().get(0).isOutputText()); + + // Delete + api.deleteResponse(responseId); + + // After deletion, get should throw 404 + ApiException ex = assertThrows(ApiException.class, + () -> api.getResponse(responseId, List.of())); + assertEquals(404, ex.getStatusCode()); + } + + @Test + @DisplayName("Get non-existent response returns 404") + void getNonExistentResponseThrows404() { + ApiException ex = assertThrows(ApiException.class, + () -> api.getResponse("caresp_" + "A".repeat(50), List.of())); + assertEquals(404, ex.getStatusCode()); + } + + @Test + @DisplayName("Multiple creates produce distinct response IDs") + void multipleCreatesProduceDistinctIds() throws ApiException { + CreateResponse r1 = api.createResponse(createRequest("First")); + CreateResponse r2 = api.createResponse(createRequest("Second")); + + assertNotEquals(r1.response().id(), r2.response().id()); + + // Both are retrievable + assertNotNull(api.getResponse(r1.response().id(), List.of())); + assertNotNull(api.getResponse(r2.response().id(), List.of())); + } + + @Test + @DisplayName("List input items for a stored response") + void listInputItemsForStoredResponse() throws ApiException { + CreateResponse created = api.createResponse(createRequest("Hello")); + String responseId = created.response().id(); + + AgentServerResponseItemList itemList = api.listInputItems( + responseId, null, null, null, null, List.of()); + assertNotNull(itemList); + assertNotNull(itemList.responseItemList()); + } + + @Test + @DisplayName("List input items with after/before cursors windows the result") + void listInputItemsCursorWindow() throws ApiException { + // Seed a stored response and 5 input items in deterministic order: it1..it5 + CreateResponse created = api.createResponse(createRequest("anchor")); + String responseId = created.response().id(); + List seeded = new java.util.ArrayList<>(); + for (int i = 1; i <= 5; i++) { + ResponseOutputMessage msg = ResponseOutputMessage.builder() + .id("it" + i) + .status(ResponseOutputMessage.Status.COMPLETED) + .addContent(ResponseOutputMessage.Content.ofOutputText( + ResponseOutputText.builder().text("input " + i).annotations(List.of()).build())) + .build(); + seeded.add(ResponseItem.ofResponseOutputMessage(msg)); + } + provider.saveInputItemsAsync(responseId, seeded).join(); + + // No cursors → all 5 items. + assertEquals(5, api.listInputItems(responseId, null, "asc", null, null, List.of()) + .responseItemList().data().size()); + + // after=it2 → it3, it4, it5 (3 items, excluding it2). + ResponseItemList afterPage = api.listInputItems( + responseId, null, "asc", "it2", null, List.of()).responseItemList(); + assertEquals(3, afterPage.data().size()); + assertEquals("it3", afterPage.firstId()); + assertEquals("it5", afterPage.lastId()); + + // before=it4 → it1, it2, it3 (3 items, excluding it4). + ResponseItemList beforePage = api.listInputItems( + responseId, null, "asc", null, "it4", List.of()).responseItemList(); + assertEquals(3, beforePage.data().size()); + assertEquals("it1", beforePage.firstId()); + assertEquals("it3", beforePage.lastId()); + + // after=it1 + before=it5 → it2, it3, it4 (open interval). + ResponseItemList windowPage = api.listInputItems( + responseId, null, "asc", "it1", "it5", List.of()).responseItemList(); + assertEquals(3, windowPage.data().size()); + assertEquals("it2", windowPage.firstId()); + assertEquals("it4", windowPage.lastId()); + + // before=it4 + order=desc → it3, it2, it1 (window first, then reverse). + ResponseItemList descPage = api.listInputItems( + responseId, null, "desc", null, "it4", List.of()).responseItemList(); + assertEquals(3, descPage.data().size()); + assertEquals("it3", descPage.firstId()); + assertEquals("it1", descPage.lastId()); + } + + @Test + @DisplayName("List input items for non-existent response throws 404") + void listInputItemsNonExistentThrows404() { + assertThrows(ApiException.class, + () -> api.listInputItems("caresp_" + "A".repeat(50), null, null, null, null, List.of())); + } + + @Test + @DisplayName("Deleting a non-existent response is a no-op") + void deleteNonExistentIsNoOp() { + assertDoesNotThrow(() -> api.deleteResponse("caresp_" + "A".repeat(50))); + } + + @Test + @DisplayName("Malformed response ID is rejected with 400") + void malformedIdReturns400() { + ApiException ex = assertThrows(ApiException.class, + () -> api.getResponse("not-a-valid-id", List.of())); + assertEquals(400, ex.getStatusCode()); + assertEquals("Malformed identifier.", ex.getError().message()); + + // Validation happens before lookup on every response-ID endpoint. + assertEquals(400, assertThrows(ApiException.class, + () -> api.cancelResponse("caresp_tooshort")).getStatusCode()); + assertEquals(400, assertThrows(ApiException.class, + () -> api.deleteResponse("resp_wrongprefix" + "A".repeat(50))).getStatusCode()); + assertEquals(400, assertThrows(ApiException.class, + () -> api.listInputItems("bad id", null, null, null, null, List.of())).getStatusCode()); + assertEquals(400, assertThrows(ApiException.class, + () -> api.replayResponseStream("caresp_short", 0)).getStatusCode()); + } + } + + // ══════════════════════════════════════════════════════════════ + // Streaming response lifecycle + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Streaming response lifecycle") + class StreamingResponseLifecycle { + + @BeforeEach + void setUp() { + ResponseHandler handler = new ResponseHandler() { + @Override + public ResponseEventStream createAsync(ResponseContext ctx, AgentServerCreateResponse request) { + ResponseEventStream stream = ResponseEventStream.create(ctx, request); + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg + .emitAdded() + .addTextPart(text -> text + .emitAdded() + .emitDelta("Hello ") + .emitDelta("World!") + .emitDone("Hello World!")) + .emitDone()) + .emitCompleted(); + return stream; + } + }; + api = ResponsesApi.builder() + .responseHandler(handler) + .provider(provider) + .build(); + } + + @Test + @DisplayName("Streaming create returns events with proper lifecycle") + void streamingCreateReturnsEventStream() throws ApiException { + AgentServerCreateResponse request = createRequest("Hi"); + ResponseEventStream stream = api.createStreamingResponse(request); + + assertNotNull(stream); + List events = stream.getEvents(); + assertFalse(events.isEmpty()); + + // Verify lifecycle event names in order + List eventNames = events.stream() + .map(ResponseEvent::eventName) + .toList(); + + assertTrue(eventNames.contains("response.created")); + assertTrue(eventNames.contains("response.in_progress")); + assertTrue(eventNames.contains("response.output_item.added")); + assertTrue(eventNames.contains("response.content_part.added")); + assertTrue(eventNames.contains("response.output_text.delta")); + assertTrue(eventNames.contains("response.output_text.done")); + assertTrue(eventNames.contains("response.content_part.done")); + assertTrue(eventNames.contains("response.output_item.done")); + assertTrue(eventNames.contains("response.completed")); + } + + @Test + @DisplayName("Streaming response produces valid final Response snapshot") + void streamingProducesValidResponseSnapshot() throws ApiException { + AgentServerCreateResponse request = createRequest("Hi"); + ResponseEventStream stream = api.createStreamingResponse(request); + + Response snapshot = stream.getResponse(); + assertNotNull(snapshot); + assertEquals(java.util.Optional.of(ResponseStatus.COMPLETED), snapshot.status()); + assertFalse(snapshot.output().isEmpty()); + } + + @Test + @DisplayName("Streamed response is persisted and retrievable after completion") + void streamedResponseIsPersisted() throws Exception { + AgentServerCreateResponse request = createRequest("Hi"); + ResponseEventStream stream = api.createStreamingResponse(request); + Response snapshot = stream.getResponse(); + + // Wait briefly for async persistence + Thread.sleep(200); + + Response fetched = api.getResponse(snapshot.id(), List.of()); + assertNotNull(fetched); + assertEquals(snapshot.id(), fetched.id()); + } + } + + // ══════════════════════════════════════════════════════════════ + // Builder validation + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Builder validation") + class BuilderValidation { + + @Test + @DisplayName("Builder requires responseHandler") + void builderRequiresHandler() { + assertThrows(NullPointerException.class, + () -> ResponsesApi.builder().build()); + } + + @Test + @DisplayName("Builder defaults to InMemoryResponseProvider") + void builderDefaultsToInMemoryProvider() { + ResponseHandler handler = new ResponseHandler() { + }; + ResponsesApi builtApi = ResponsesApi.builder() + .responseHandler(handler) + .build(); + assertNotNull(builtApi); + } + + @Test + @DisplayName("ResponsesApi.create() convenience method works") + void createConvenienceMethod() { + ResponseHandler handler = new ResponseHandler() { + }; + ResponsesApi builtApi = ResponsesApi.create(handler); + assertNotNull(builtApi); + } + } +} + + + diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/SerializationIntegrationTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/SerializationIntegrationTest.java new file mode 100644 index 000000000000..2270840060b0 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/SerializationIntegrationTest.java @@ -0,0 +1,376 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.agentserver.api.serialization.ObjectMapperFactory; +import com.openai.core.JsonMissing; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseOutputText; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ToolChoiceOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for JSON serialization/deserialization round-trips using + * the ObjectMapperFactory. Tests the complete serialize → deserialize pipeline + * for key protocol types. + */ +class SerializationIntegrationTest { + + private final ObjectMapper mapper = ObjectMapperFactory.getObjectMapper(); + + // ══════════════════════════════════════════════════════════════ + // AgentServerCreateResponse deserialization + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("AgentServerCreateResponse deserialization") + class CreateResponseDeserialization { + + @Test + @DisplayName("Deserialize request with text input") + void deserializeTextInput() throws Exception { + String json = """ + { + "input": "Hello, agent!", + "model": "gpt-4o" + } + """; + + AgentServerCreateResponse request = mapper.readValue(json, AgentServerCreateResponse.class); + assertNotNull(request); + assertEquals("Hello, agent!", request.inputText()); + assertNull(request.agent()); + } + + @Test + @DisplayName("Deserialize request with agent_reference") + void deserializeWithAgentReference() throws Exception { + String json = """ + { + "input": "Hello", + "model": "gpt-4o", + "agent_reference": { + "type": "agent_reference", + "name": "my-agent", + "version": "1.0", + "label": "prod" + } + } + """; + + AgentServerCreateResponse request = mapper.readValue(json, AgentServerCreateResponse.class); + assertNotNull(request.agent()); + assertEquals("my-agent", request.agent().name()); + assertEquals("1.0", request.agent().version()); + assertEquals(AgentReferenceType.AGENT_REFERENCE, request.agent().type()); + } + + @Test + @DisplayName("Deserialize request with legacy 'agent' field") + void deserializeWithLegacyAgentField() throws Exception { + String json = """ + { + "input": "Hello", + "model": "gpt-4o", + "agent": { + "type": "agent_reference", + "name": "legacy-agent", + "version": "0.9", + "label": "staging" + } + } + """; + + AgentServerCreateResponse request = mapper.readValue(json, AgentServerCreateResponse.class); + assertNotNull(request.agent()); + assertEquals("legacy-agent", request.agent().name()); + } + + @Test + @DisplayName("Deserialize request with structured input items") + void deserializeStructuredInput() throws Exception { + String json = """ + { + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "What is the weather?"} + ] + } + ], + "model": "gpt-4o" + } + """; + + AgentServerCreateResponse request = mapper.readValue(json, AgentServerCreateResponse.class); + assertNotNull(request.responseCreateParams()); + assertTrue(request.responseCreateParams().input().isPresent()); + } + + @Test + @DisplayName("Deserialize request with message items missing 'type' discriminator gets fixed") + void deserializeMissingTypeDiscriminator() throws Exception { + // The custom deserializer should inject type: "message" for items with role+content but no type + String json = """ + { + "input": [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "No type field here"} + ] + } + ], + "model": "gpt-4o" + } + """; + + AgentServerCreateResponse request = mapper.readValue(json, AgentServerCreateResponse.class); + assertNotNull(request.responseCreateParams()); + assertTrue(request.responseCreateParams().input().isPresent()); + } + + @Test + @DisplayName("Deserialize request with metadata containing response_id") + void deserializeWithMetadata() throws Exception { + String json = """ + { + "input": "Hello", + "model": "gpt-4o", + "metadata": { + "response_id": "resp_custom_id_123" + } + } + """; + + AgentServerCreateResponse request = mapper.readValue(json, AgentServerCreateResponse.class); + assertNotNull(request); + // The metadata should be accessible through the response create params + assertTrue(request.responseCreateParams().metadata().isPresent()); + } + } + + // ══════════════════════════════════════════════════════════════ + // Response serialization + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Response serialization") + class ResponseSerialization { + + @Test + @DisplayName("Response serializes createdAt as integer (not decimal)") + void createdAtAsInteger() throws Exception { + ResponseOutputMessage message = ResponseOutputMessage.builder() + .id("msg_test") + .status(ResponseOutputMessage.Status.COMPLETED) + .addContent(ResponseOutputMessage.Content.ofOutputText( + ResponseOutputText.builder().text("Test").annotations(List.of()).build())) + .build(); + + Response response = Response.builder() + .id("resp_test") + .createdAt(1700000000.0) + .addOutput(ResponseOutputItem.ofMessage(message)) + .model("gpt-4o") + .parallelToolCalls(false) + .tools(List.of()) + .status(ResponseStatus.COMPLETED) + + .error(JsonMissing.of()) + .incompleteDetails(JsonMissing.of()) + .instructions(JsonMissing.of()) + .metadata(JsonMissing.of()) + .temperature(JsonMissing.of()) + .topP(JsonMissing.of()) + .toolChoice(ToolChoiceOptions.AUTO) + .build(); + + String json = mapper.writeValueAsString(response); + JsonNode node = mapper.readTree(json); + + // createdAt should be present and numeric + assertTrue(node.has("created_at")); + assertTrue(node.get("created_at").isNumber()); + assertEquals(1700000000L, node.get("created_at").longValue(), + "createdAt should serialize as the correct epoch timestamp"); + } + + @Test + @DisplayName("Null and JsonNull fields are excluded from serialization") + void nullFieldsExcluded() throws Exception { + Response response = Response.builder() + .id("resp_minimal") + .createdAt(1700000000.0) + .output(List.of()) + .model("gpt-4o") + .parallelToolCalls(false) + .tools(List.of()) + .status(ResponseStatus.COMPLETED) + + .error(JsonMissing.of()) + .incompleteDetails(JsonMissing.of()) + .instructions(JsonMissing.of()) + .metadata(JsonMissing.of()) + .temperature(JsonMissing.of()) + .topP(JsonMissing.of()) + .toolChoice(ToolChoiceOptions.AUTO) + .build(); + + String json = mapper.writeValueAsString(response); + JsonNode node = mapper.readTree(json); + + // Fields that weren't set should not appear + assertFalse(node.has("error") && !node.get("error").isNull(), + "Error field should not be present as a non-null value"); + } + + @Test + @DisplayName("CreateResponse serializes with unwrapped Response fields") + void createResponseUnwrapped() throws Exception { + ResponseOutputText outputText = ResponseOutputText.builder() + .text("Test output") + .annotations(List.of()) + .build(); + + ResponseOutputMessage message = ResponseOutputMessage.builder() + .id("msg_cr_test") + .status(ResponseOutputMessage.Status.COMPLETED) + .addContent(ResponseOutputMessage.Content.ofOutputText(outputText)) + .build(); + + Response response = Response.builder() + .id("resp_cr_test") + .createdAt(1700000000.0) + .addOutput(ResponseOutputItem.ofMessage(message)) + .model("gpt-4o") + .parallelToolCalls(false) + .tools(List.of()) + .status(ResponseStatus.COMPLETED) + + .error(JsonMissing.of()) + .incompleteDetails(JsonMissing.of()) + .instructions(JsonMissing.of()) + .metadata(JsonMissing.of()) + .temperature(JsonMissing.of()) + .topP(JsonMissing.of()) + .toolChoice(ToolChoiceOptions.AUTO) + .build(); + + CreateResponse createResponse = new CreateResponse( + new AgentReference(AgentReferenceType.AGENT_REFERENCE, "test-agent", "1.0", "prod"), + response); + + String json = mapper.writeValueAsString(createResponse); + JsonNode node = mapper.readTree(json); + + // Response fields should be unwrapped at top level + assertTrue(node.has("id")); + assertEquals("resp_cr_test", node.get("id").asText()); + + // Agent should be nested + assertTrue(node.has("agent")); + assertEquals("test-agent", node.get("agent").get("name").asText()); + } + } + + // ══════════════════════════════════════════════════════════════ + // ResponseEventStream event serialization + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Event stream serialization") + class EventStreamSerialization { + + @Test + @DisplayName("Streaming events serialize to valid JSON") + void streamingEventsSerialize() throws Exception { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("test") + .model("test-model") + .build() + ._body(); + AgentServerCreateResponse request = new AgentServerCreateResponse(null, body); + + ResponseContext context = ResponseContext.builder() + .responseId("resp_AAAAAAAAAAAAAAAAAA" + "B".repeat(32)) + .provider(ResponsesProvider.inMemory()) + .request(body) + .build(); + + ResponseEventStream stream = ResponseEventStream.create(context, request); + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg.outputItemMessage("Hello!")) + .emitCompleted(); + + // Serialize each event + for (ResponseEvent event : stream.getEvents()) { + String json = mapper.writeValueAsString(event.streamEvent()); + assertNotNull(json); + assertFalse(json.isEmpty()); + // Verify it's valid JSON + JsonNode node = mapper.readTree(json); + assertNotNull(node); + } + } + } + + // ══════════════════════════════════════════════════════════════ + // AgentReference serialization round-trip + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("AgentReference round-trip") + class AgentReferenceRoundTrip { + + @Test + @DisplayName("Serialize and deserialize AgentReference") + void roundTrip() throws Exception { + // Deserialize from JSON (using lowercase "agent_reference" matching @JsonCreator) + String json = """ + { + "type": "agent_reference", + "name": "my-agent", + "version": "2.0", + "label": "canary" + } + """; + + AgentReference deserialized = mapper.readValue(json, AgentReference.class); + assertEquals("my-agent", deserialized.name()); + assertEquals("2.0", deserialized.version()); + assertEquals("canary", deserialized.label()); + assertEquals(AgentReferenceType.AGENT_REFERENCE, deserialized.type()); + + // Re-serialize and verify key fields + String reserialized = mapper.writeValueAsString(deserialized); + JsonNode node = mapper.readTree(reserialized); + assertEquals("my-agent", node.get("name").asText()); + } + } +} + + + + + + + + + diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/SessionIdResolverTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/SessionIdResolverTest.java new file mode 100644 index 000000000000..2e01009a5cba --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/SessionIdResolverTest.java @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api; + +import com.openai.core.JsonValue; +import com.openai.models.responses.ResponseCreateParams; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link SessionIdResolver}. + */ +class SessionIdResolverTest { + + private static AgentServerCreateResponse req(java.util.function.Consumer tweak) { + ResponseCreateParams.Builder b = ResponseCreateParams.builder() + .input("hi").model("test-model"); + tweak.accept(b); + return new AgentServerCreateResponse(null, b.build()._body()); + } + + private static AgentServerCreateResponse req() { + return req(b -> { + }); + } + + @Test + @DisplayName("Tier 1: payload field agent_session_id wins over everything") + void tier1Payload() { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("hi").model("test-model") + .build() + ._body() + .toBuilder() + .putAdditionalProperty("agent_session_id", JsonValue.from("client-supplied")) + .build(); + AgentServerCreateResponse request = new AgentServerCreateResponse(null, body); + + // Even with env var present, tier-1 wins. + assertEquals("client-supplied", + SessionIdResolver.resolve(request, "env-session-id")); + } + + @Test + @DisplayName("Tier 2: FOUNDRY_AGENT_SESSION_ID is used when no payload field") + void tier2EnvVar() { + assertEquals("env-session-id", + SessionIdResolver.resolve(req(), "env-session-id")); + } + + @Test + @DisplayName("Tier 3: no payload and no env → derived 63-char lowercase hex") + void tier3DerivedRandomWhenNoContext() { + String a = SessionIdResolver.resolve(req(), null); + String b = SessionIdResolver.resolve(req(), ""); + // 63 lowercase hex chars + assertEquals(63, a.length()); + assertTrue(a.matches("[0-9a-f]{63}"), "expected 63-char lowercase hex, got: " + a); + // Without conversational context, the result is random — two calls differ. + assertNotEquals(a, b); + } + + @Test + @DisplayName("Tier 3: deterministic when conversation_id is present") + void tier3DeterministicWithConversation() { + AgentServerCreateResponse request = req(b -> b.conversation("convXYZ")); + String first = SessionIdResolver.resolve(request, null); + String second = SessionIdResolver.resolve(request, null); + assertEquals(first, second, "derivation must be deterministic for the same input"); + assertEquals(63, first.length()); + assertTrue(first.matches("[0-9a-f]{63}")); + } + + @Test + @DisplayName("Tier 3: conversation_id takes priority over previous_response_id") + void tier3ConversationBeatsPrev() { + AgentServerCreateResponse convReq = req(b -> b.conversation("convABC")); + // Same conversation, different previous_response_id → same session ID. + AgentServerCreateResponse convReqWithPrev = req(b -> b + .conversation("convABC") + .previousResponseId("caresp_" + "A".repeat(50))); + assertEquals( + SessionIdResolver.resolve(convReq, null), + SessionIdResolver.resolve(convReqWithPrev, null)); + } + + @Test + @DisplayName("Tier 3: previous_response_id used when conversation_id absent") + void tier3FallsBackToPrevious() { + String prevA = "caresp_AAAAAAAAAAAAAAAAAA" + "B".repeat(32); + String prevB = "caresp_CCCCCCCCCCCCCCCCCC" + "D".repeat(32); + // Different previous IDs → different session IDs (different partition keys). + assertNotEquals( + SessionIdResolver.resolve(req(b -> b.previousResponseId(prevA)), null), + SessionIdResolver.resolve(req(b -> b.previousResponseId(prevB)), null)); + } + + @Test + @DisplayName("Tier 3: different agent identity yields different derived session IDs") + void tier3AgentIdentityMatters() { + ResponseCreateParams.Body body = ResponseCreateParams.builder() + .input("hi").model("test-model").conversation("conv1").build()._body(); + AgentServerCreateResponse a = new AgentServerCreateResponse( + new AgentReference(AgentReferenceType.AGENT_REFERENCE, "agent-a", "1.0", null), body); + AgentServerCreateResponse b = new AgentServerCreateResponse( + new AgentReference(AgentReferenceType.AGENT_REFERENCE, "agent-b", "1.0", null), body); + assertNotEquals(SessionIdResolver.resolve(a, null), SessionIdResolver.resolve(b, null)); + } +} + + diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/implementation/FoundryStorageProviderActivationTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/implementation/FoundryStorageProviderActivationTest.java new file mode 100644 index 000000000000..e4ef579ab6ea --- /dev/null +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/implementation/FoundryStorageProviderActivationTest.java @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.implementation; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit test for the activation contract of {@link FoundryStorageProvider}. + *

+ * The provider is auto-activated by {@code ResponsesApi.Builder.resolveProvider()} + * when {@code FOUNDRY_HOSTING_ENVIRONMENT} is set. Its {@link FoundryStorageProvider#fromEnvironment()} + * factory requires {@code FOUNDRY_PROJECT_ENDPOINT} and must fail fast (rather than + * silently produce a misconfigured provider) when it is absent — that signal is what + * the builder relies on to log + fall back to in-memory. + */ +class FoundryStorageProviderActivationTest { + + @Test + @DisplayName("fromEnvironment throws when FOUNDRY_PROJECT_ENDPOINT is not set") + @DisabledIfEnvironmentVariable(named = "FOUNDRY_PROJECT_ENDPOINT", matches = ".+", + disabledReason = "Only meaningful when the env var is absent") + void fromEnvironmentFailsWithoutEndpoint() { + IllegalStateException ex = assertThrows(IllegalStateException.class, + FoundryStorageProvider::fromEnvironment); + assertTrue(ex.getMessage().contains("FOUNDRY_PROJECT_ENDPOINT"), + "message should explain the missing env var, was: " + ex.getMessage()); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/pom.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/pom.xml new file mode 100644 index 000000000000..6e744aa13677 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + azure-agentserver-doc-samples + azure-agentserver-doc-samples + Documentation code samples for the Azure AI Foundry Agent Server Server + + + + com.microsoft.agentserver + azure-agentserver-api + + + com.microsoft.agentserver + azure-agentserver-api-jaxrs + + + com.microsoft.agentserver + azure-agentserver-jersey + compile + + + com.openai + openai-java-client-okhttp + compile + + + org.apache.logging.log4j + log4j-api + + + org.apache.logging.log4j + log4j-core + + + org.apache.logging.log4j + log4j-slf4j2-impl + + + org.apache.logging.log4j + log4j-jul + runtime + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + ${maven-assembly-plugin.version} + + + + com.microsoft.agentserver.Main + + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/Main.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/Main.java new file mode 100644 index 000000000000..98159085a340 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/Main.java @@ -0,0 +1,17 @@ +package com.microsoft.agentserver; + +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.sample1.EchoHandler; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; + +public class Main { + public static void main(String[] args) throws InterruptedException { + JerseyAgentServerAdaptorService.buildAgent( + ResponsesApi.builder() + .responseHandler(new EchoHandler()) + .build() + ); + + Thread.sleep(Long.MAX_VALUE); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample1/EchoHandler.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample1/EchoHandler.java new file mode 100644 index 000000000000..7e0006c713b2 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample1/EchoHandler.java @@ -0,0 +1,67 @@ +package com.microsoft.agentserver.sample1; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.CreateResponse; +import com.microsoft.agentserver.api.ResponseBuilder; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; +import com.openai.models.responses.ResponseOutputText; + +import java.util.ArrayList; +import java.util.concurrent.Executors; + +/** + * A simple echo handler that reads the input text from the request + * and streams back "Echo: {input}" as a single text output message. + */ +public class EchoHandler implements ResponseHandler { + + public static final java.util.concurrent.ExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadExecutor(); + + @Override + public CreateResponse createResponse( + ResponseContext responseContext, + AgentServerCreateResponse request) { + + String inputText = request.inputText(); + if (!inputText.isEmpty()) { + ResponseOutputText responseOutputText = ResponseOutputText.builder() + .text("Echo: " + inputText) + .annotations(new ArrayList<>()) + .build(); + + return new CreateResponse( + request.agent(), + ResponseBuilder.convertOutputToResponse(request, responseOutputText) + ); + } + + throw new IllegalArgumentException("No text input provided in the request"); + } + + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + String inputText = request.inputText(); + + ResponseEventStream stream = ResponseEventStream.create(responseContext, request) + .emitCreated() + .emitInProgress(); + + EXECUTOR_SERVICE.execute(() -> { + try { + stream.addOutputMessage(msg -> msg + .emitAdded() + .addTextPart(text -> text + .emitAdded() + .emitDelta("Echo: " + inputText) + .emitDone("Echo: " + inputText)) + .emitDone()); + } finally { + stream.emitCompleted(); + } + }); + + return stream; + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample1/Sample1Snippets.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample1/Sample1Snippets.java new file mode 100644 index 000000000000..63b3a3bec66a --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample1/Sample1Snippets.java @@ -0,0 +1,38 @@ +package com.microsoft.agentserver.sample1; + +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; + +/** + * Sample 1: Getting started — starts an echo agent server. + *

+ * Test with: + *

{@code
+ * curl -X POST http://localhost:8088/responses \
+ *   -H "Content-Type: application/json" \
+ *   -d '{"model": "echo", "stream": true, "input": "Hello, world!"}' \
+ *   --no-buffer
+ * }
+ */ +public class Sample1Snippets { + public static void main(String[] args) throws InterruptedException { + JerseyAgentServerAdaptorService.buildAgent( + ResponsesApi.create(new EchoHandler()) + ); + + System.out.println("Server started. Test with:"); + System.out.println(" # Non-streaming:"); + System.out.println(" curl -X POST http://localhost:8088/responses \\"); + System.out.println(" -H \"Content-Type: application/json\" \\"); + System.out.println(" -d '{\"model\": \"echo\", \"input\": \"Hello, world!\"}'"); + System.out.println(); + System.out.println(" # Streaming:"); + System.out.println(" curl -X POST http://localhost:8088/responses \\"); + System.out.println(" -H \"Content-Type: application/json\" \\"); + System.out.println(" -d '{\"model\": \"echo\", \"stream\": true, \"input\": \"Hello, world!\"}' \\"); + System.out.println(" --no-buffer"); + + Thread.sleep(Long.MAX_VALUE); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample10/Sample10Snippets.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample10/Sample10Snippets.java new file mode 100644 index 000000000000..6cf7c48f3d30 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample10/Sample10Snippets.java @@ -0,0 +1,34 @@ +package com.microsoft.agentserver.sample10; + +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; + +/** + * Sample 10: Streaming OpenAI upstream — starts a server that proxies requests + * to an upstream OpenAI-compatible endpoint and streams the response back. + */ +public class Sample10Snippets { + public static void main(String[] args) throws InterruptedException { + // Build the upstream OpenAI client. + String apiKey = System.getenv("OPENAI_API_KEY") != null + ? System.getenv("OPENAI_API_KEY") : "your-api-key"; + String endpoint = System.getenv("UPSTREAM_ENDPOINT") != null + ? System.getenv("UPSTREAM_ENDPOINT") : "https://api.openai.com/v1"; + + OpenAIClient upstream = OpenAIOkHttpClient.builder() + .apiKey(apiKey) + .baseUrl(endpoint) + .build(); + + JerseyAgentServerAdaptorService.buildAgent( + ResponsesApi.create( + new StreamingUpstreamHandler(upstream) + ) + ); + + Thread.sleep(Long.MAX_VALUE); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample10/StreamingUpstreamHandler.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample10/StreamingUpstreamHandler.java new file mode 100644 index 000000000000..3999a397fdb7 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample10/StreamingUpstreamHandler.java @@ -0,0 +1,56 @@ +package com.microsoft.agentserver.sample10; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; +import com.openai.client.OpenAIClient; +import com.openai.models.responses.ResponseCreateParams; + +import java.util.concurrent.Executors; + +/** + * A handler that forwards requests to an upstream OpenAI-compatible endpoint + * and streams the response back token by token. Demonstrates the proxy/passthrough pattern. + * Equivalent to the C# StreamingUpstreamHandler. + */ +public class StreamingUpstreamHandler implements ResponseHandler { + + public static final java.util.concurrent.ExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadExecutor(); + private final OpenAIClient upstream; + + public StreamingUpstreamHandler(OpenAIClient upstream) { + this.upstream = upstream; + } + + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + ResponseEventStream stream = ResponseEventStream.create(responseContext, request) + .emitCreated() + .emitInProgress(); + + EXECUTOR_SERVICE.execute(() -> { + try { + // Build the upstream request using the OpenAI Java SDK. + ResponseCreateParams params = ResponseCreateParams.builder() + .model(request.responseCreateParams().model().get()) + .input(request.responseCreateParams().input() + .orElse(ResponseCreateParams.Input.ofText(""))) + .instructions(request.responseCreateParams().instructions().orElse(null)) + .build(); + + // Forward the upstream stream — handles output message lifecycle, + // text deltas, and failure detection automatically. + try (var streamingResponse = upstream.responses().createStreaming(params)) { + stream.forwardUpstream(streamingResponse.stream()); + } + + stream.emitCompleted(); + } catch (Exception e) { + stream.emitFailed(); + } + }); + + return stream; + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample2/Sample2Snippets.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample2/Sample2Snippets.java new file mode 100644 index 000000000000..05f57bce6eeb --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample2/Sample2Snippets.java @@ -0,0 +1,29 @@ +package com.microsoft.agentserver.sample2; + +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; + +/** + * Sample 2: Streaming text deltas — starts a server with a handler that + * simulates token-by-token streaming from an LLM. + *

+ * Set CA_LOG_REQUESTS=true to enable request/exception logging. + */ +public class Sample2Snippets { + public static void main(String[] args) throws InterruptedException { + JerseyAgentServerAdaptorService.buildAgent( + ResponsesApi.create( + new StreamingHandler() + ) + ); + + System.out.println("Server started. Test with:"); + System.out.println(" curl -X POST http://localhost:8088/responses \\"); + System.out.println(" -H \"Content-Type: application/json\" \\"); + System.out.println(" -d '{\"model\": \"streaming\", \"stream\": true, \"input\": \"Hello!\"}' \\"); + System.out.println(" --no-buffer"); + + Thread.sleep(Long.MAX_VALUE); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample2/StreamingHandler.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample2/StreamingHandler.java new file mode 100644 index 000000000000..972b7cd3c789 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample2/StreamingHandler.java @@ -0,0 +1,55 @@ +package com.microsoft.agentserver.sample2; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; + +import java.util.concurrent.Executors; + +/** + * A streaming handler that simulates an LLM producing tokens one at a time. + * Each token is emitted as a separate delta event — the client sees tokens in real time. + */ +public class StreamingHandler implements ResponseHandler { + + public static final java.util.concurrent.ExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadExecutor(); + + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + ResponseEventStream stream = ResponseEventStream.create(responseContext, request) + .emitCreated() + .emitInProgress(); + + EXECUTOR_SERVICE.execute(() -> { + try { + stream.addOutputMessage(msg -> msg + .emitAdded() + .addTextPart(text -> { + text.emitAdded(); + + // Simulate an LLM producing tokens one at a time. + // Replace this with your actual model call. + String[] tokens = {"Hello", ", ", "world", "!"}; + for (String token : tokens) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + text.emitDelta(token); + } + + text.emitDone("Hello, world!"); + }) + .emitDone()); + } finally { + stream.emitCompleted(); + } + }); + + return stream; + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/GreetingHandler.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/GreetingHandler.java new file mode 100644 index 000000000000..daa7b346a8ef --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/GreetingHandler.java @@ -0,0 +1,38 @@ +package com.microsoft.agentserver.sample3; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; + +import java.util.concurrent.Executors; + +/** + * A greeting handler that emits a complete text message using the convenience API. + * Equivalent to the C# GreetingHandler using OutputItemMessage(). + */ +public class GreetingHandler implements ResponseHandler { + + public static final java.util.concurrent.ExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadExecutor(); + + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + String inputText = request.inputText(); + + ResponseEventStream stream = ResponseEventStream.create(responseContext, request) + .emitCreated() + .emitInProgress(); + + EXECUTOR_SERVICE.execute(() -> { + try { + // Emit a complete text message in one call using the convenience method. + stream.addOutputMessage(msg -> msg + .outputItemMessage("Hello! You said: \"" + inputText + "\"")); + } finally { + stream.emitCompleted(); + } + }); + + return stream; + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/GreetingHandlerFullControl.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/GreetingHandlerFullControl.java new file mode 100644 index 000000000000..f63aaeed4780 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/GreetingHandlerFullControl.java @@ -0,0 +1,53 @@ +package com.microsoft.agentserver.sample3; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; + +import java.util.concurrent.Executors; + +/** + * A greeting handler with full control over each individual SSE event. + * Demonstrates the builder API for fine-grained control over the event lifecycle. + * Equivalent to the C# GreetingHandlerFullControl. + */ +public class GreetingHandlerFullControl implements ResponseHandler { + + public static final java.util.concurrent.ExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadExecutor(); + + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + String inputText = request.inputText(); + + ResponseEventStream stream = ResponseEventStream.create(responseContext, request) + .emitCreated() // response.created + .emitInProgress(); // response.in_progress + + EXECUTOR_SERVICE.execute(() -> { + try { + String reply = "Hello! You said: \"" + inputText + "\""; + + // Add a message output item with full control over each event. + stream.addOutputMessage(msg -> { + msg.emitAdded(); // response.output_item.added + + msg.addTextPart(text -> { + text.emitAdded(); // response.content_part.added + + // Emit the text body — delta first, then the final "done" with full text. + text.emitDelta(reply); // response.output_text.delta + text.emitDone(reply); // response.output_text.done + // response.content_part.done + }); + + msg.emitDone(); // response.output_item.done + }); + } finally { + stream.emitCompleted(); // response.completed + } + }); + + return stream; + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/Sample3Snippets.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/Sample3Snippets.java new file mode 100644 index 000000000000..189cc47a7d55 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/Sample3Snippets.java @@ -0,0 +1,51 @@ +package com.microsoft.agentserver.sample3; + +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; + +/** + * Sample 3: Full control ResponseEventStream — starts a server with the GreetingHandler. + * Demonstrates convenience, streaming, and full-control handler variants. + */ +public class Sample3Snippets { + public static void main(String[] args) throws InterruptedException { + // Port 8088: Convenience handler + JerseyAgentServerAdaptorService.buildAgent( + ResponsesApi.create(new GreetingHandler()) + ); + + // Port 8089: Streaming greeting handler + JerseyAgentServerAdaptorService.buildAgent( + "http://0.0.0.0:8089", + ResponsesApi.create(new StreamingGreetingHandler()) + ); + + // Port 8090: Full control handler + JerseyAgentServerAdaptorService.buildAgent( + "http://0.0.0.0:8090", + ResponsesApi.create(new GreetingHandlerFullControl()) + ); + + System.out.println("Servers started. Test with:"); + System.out.println(" # Convenience (port 8088):"); + System.out.println(" curl -X POST http://localhost:8088/responses \\"); + System.out.println(" -H \"Content-Type: application/json\" \\"); + System.out.println(" -d '{\"model\": \"greeting\", \"stream\": true, \"input\": \"Hi there!\"}' \\"); + System.out.println(" --no-buffer"); + System.out.println(); + System.out.println(" # Streaming deltas (port 8089):"); + System.out.println(" curl -X POST http://localhost:8089/responses \\"); + System.out.println(" -H \"Content-Type: application/json\" \\"); + System.out.println(" -d '{\"model\": \"greeting\", \"stream\": true, \"input\": \"Hi there!\"}' \\"); + System.out.println(" --no-buffer"); + System.out.println(); + System.out.println(" # Full control (port 8090):"); + System.out.println(" curl -X POST http://localhost:8090/responses \\"); + System.out.println(" -H \"Content-Type: application/json\" \\"); + System.out.println(" -d '{\"model\": \"greeting\", \"stream\": true, \"input\": \"Hi there!\"}' \\"); + System.out.println(" --no-buffer"); + + Thread.sleep(Long.MAX_VALUE); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/StreamingGreetingHandler.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/StreamingGreetingHandler.java new file mode 100644 index 000000000000..5ccce56bd302 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample3/StreamingGreetingHandler.java @@ -0,0 +1,56 @@ +package com.microsoft.agentserver.sample3; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; + +import java.util.concurrent.Executors; + +/** + * A streaming greeting handler that simulates token-by-token streaming. + * Each token is emitted as a separate delta event — the client sees tokens in real time. + * Equivalent to the C# StreamingGreetingHandler. + */ +public class StreamingGreetingHandler implements ResponseHandler { + + public static final java.util.concurrent.ExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadExecutor(); + + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + String inputText = request.inputText(); + + ResponseEventStream stream = ResponseEventStream.create(responseContext, request) + .emitCreated() + .emitInProgress(); + + EXECUTOR_SERVICE.execute(() -> { + try { + stream.addOutputMessage(msg -> msg + .emitAdded() + .addTextPart(text -> { + text.emitAdded(); + + // Stream tokens as they arrive — each chunk becomes a delta event. + String[] tokens = {"Hello! ", "You ", "said: ", "\"" + inputText + "\""}; + for (String token : tokens) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + text.emitDelta(token); + } + + text.emitDone("Hello! You said: \"" + inputText + "\""); + }) + .emitDone()); + } finally { + stream.emitCompleted(); + } + }); + + return stream; + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample4/Sample4Snippets.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample4/Sample4Snippets.java new file mode 100644 index 000000000000..3fce91b58af9 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample4/Sample4Snippets.java @@ -0,0 +1,21 @@ +package com.microsoft.agentserver.sample4; + +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; + +/** + * Sample 4: Function calling — starts a server with the WeatherHandler. + * Demonstrates emitting function calls and processing function call outputs. + */ +public class Sample4Snippets { + public static void main(String[] args) throws InterruptedException { + JerseyAgentServerAdaptorService.buildAgent( + ResponsesApi.create( + new WeatherHandler() + ) + ); + + Thread.sleep(Long.MAX_VALUE); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample4/WeatherHandler.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample4/WeatherHandler.java new file mode 100644 index 000000000000..541197ee7e04 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample4/WeatherHandler.java @@ -0,0 +1,87 @@ +package com.microsoft.agentserver.sample4; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseInputItem; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Executors; + +/** + * A weather handler demonstrating function calling using the convenience API. + * Turn 1: emits a function call for "get_weather". + * Turn 2: reads the function output and returns a text message with the result. + * Equivalent to the C# WeatherHandler (convenience version). + */ +public class WeatherHandler implements ResponseHandler { + + public static final java.util.concurrent.ExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadExecutor(); + + private static ResponseInputItem.FunctionCallOutput findFunctionCallOutput(ResponseCreateParams.Body request) { + Optional inputOpt = request.input(); + if (inputOpt.isEmpty()) { + return null; + } + ResponseCreateParams.Input input = inputOpt.get(); + if (!input.isResponse()) { + return null; + } + List items = input.asResponse(); + for (ResponseInputItem item : items) { + if (item.isFunctionCallOutput()) { + return item.asFunctionCallOutput(); + } + } + return null; + } + + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + ResponseEventStream stream = ResponseEventStream.create(responseContext, request); + + EXECUTOR_SERVICE.execute(() -> { + try { + // Check if the input contains a function call output (turn 2) + ResponseInputItem.FunctionCallOutput toolOutput = findFunctionCallOutput(request.responseCreateParams()); + + if (toolOutput != null) { + // Turn 2: function output received — return the weather as a text message + String weatherJson = toolOutput.output().isString() + ? toolOutput.output().asString() + : "{}"; + + stream.emitCreated(); + stream.emitInProgress(); + + stream.addOutputMessage(msg -> msg + .outputItemMessage("The weather is: " + weatherJson)); + + stream.emitCompleted(); + } else { + // Turn 1: emit a function call for "get_weather" + stream.emitCreated(); + stream.emitInProgress(); + + String arguments = "{\"location\":\"Seattle\",\"unit\":\"fahrenheit\"}"; + + stream.addOutputFunctionCall(funcCall -> funcCall + .emitAdded("get_weather", "call_weather_1") + .emitArgumentsDelta(arguments) + .emitArgumentsDone("get_weather", arguments) + .emitDone()); + + stream.emitCompleted(); + } + } catch (Exception e) { + stream.emitFailed(); + } + }); + + return stream; + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample4/WeatherHandlerFullControl.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample4/WeatherHandlerFullControl.java new file mode 100644 index 000000000000..e42209d1c0e9 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample4/WeatherHandlerFullControl.java @@ -0,0 +1,100 @@ +package com.microsoft.agentserver.sample4; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseInputItem; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Executors; + +/** + * A weather handler with full control over each individual SSE event. + * Demonstrates the builder API for fine-grained control over function call + * and text message lifecycles. + * Equivalent to the C# WeatherHandlerFullControl. + */ +public class WeatherHandlerFullControl implements ResponseHandler { + + public static final java.util.concurrent.ExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadExecutor(); + + private static ResponseInputItem.FunctionCallOutput findFunctionCallOutput(ResponseCreateParams.Body request) { + Optional inputOpt = request.input(); + if (inputOpt.isEmpty()) { + return null; + } + ResponseCreateParams.Input input = inputOpt.get(); + if (!input.isResponse()) { + return null; + } + List items = input.asResponse(); + for (ResponseInputItem item : items) { + if (item.isFunctionCallOutput()) { + return item.asFunctionCallOutput(); + } + } + return null; + } + + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + ResponseEventStream stream = ResponseEventStream.create(responseContext, request); + + EXECUTOR_SERVICE.execute(() -> { + try { + // Check if the input contains a function call output (turn 2) + ResponseInputItem.FunctionCallOutput toolOutput = findFunctionCallOutput(request.responseCreateParams()); + + if (toolOutput != null) { + // Turn 2: function output received — return the weather as a text message + String weatherJson = toolOutput.output().isString() + ? toolOutput.output().asString() + : "{}"; + + stream.emitCreated(); + stream.emitInProgress(); + + String reply = "The weather is: " + weatherJson; + + stream.addOutputMessage(msg -> { + msg.emitAdded(); // response.output_item.added + + msg.addTextPart(text -> { + text.emitAdded(); // response.content_part.added + text.emitDelta(reply); // response.output_text.delta + text.emitDone(reply); // response.output_text.done + // response.content_part.done (auto) + }); + + msg.emitDone(); // response.output_item.done + }); + + stream.emitCompleted(); + } else { + // Turn 1: emit a function call for "get_weather" with full control + stream.emitCreated(); + stream.emitInProgress(); + + String arguments = "{\"location\":\"Seattle\",\"unit\":\"fahrenheit\"}"; + + stream.addOutputFunctionCall(funcCall -> { + funcCall.emitAdded("get_weather", "call_weather_1"); // response.output_item.added + funcCall.emitArgumentsDelta(arguments); // response.function_call_arguments.delta + funcCall.emitArgumentsDone("get_weather", arguments); // response.function_call_arguments.done + funcCall.emitDone(); // response.output_item.done + }); + + stream.emitCompleted(); + } + } catch (Exception e) { + stream.emitFailed(); + } + }); + + return stream; + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample5/Sample5Snippets.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample5/Sample5Snippets.java new file mode 100644 index 000000000000..0c12fc2443c2 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample5/Sample5Snippets.java @@ -0,0 +1,21 @@ +package com.microsoft.agentserver.sample5; + +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; + +/** + * Sample 5: Conversation history — starts a server with the StudyTutorHandler. + * Demonstrates using previous_response_id to build multi-turn conversations. + */ +public class Sample5Snippets { + public static void main(String[] args) throws InterruptedException { + JerseyAgentServerAdaptorService.buildAgent( + ResponsesApi.create( + new StudyTutorHandler() + ) + ); + + Thread.sleep(Long.MAX_VALUE); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample5/StudyTutorHandler.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample5/StudyTutorHandler.java new file mode 100644 index 000000000000..a5997f2767ad --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample5/StudyTutorHandler.java @@ -0,0 +1,77 @@ +package com.microsoft.agentserver.sample5; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; +import com.openai.models.responses.ResponseItem; +import com.openai.models.responses.ResponseOutputMessage; + +import java.util.List; +import java.util.concurrent.Executors; + +/** + * A study tutor handler demonstrating conversation history. + * Uses ResponseContext.getHistoryAsync() to resolve previous turns and + * build context-aware responses. + * Equivalent to the C# StudyTutorHandler. + */ +public class StudyTutorHandler implements ResponseHandler { + + public static final java.util.concurrent.ExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadExecutor(); + + private static String extractMessageText(ResponseOutputMessage message) { + return message.content().stream() + .filter(ResponseOutputMessage.Content::isOutputText) + .map(content -> content.asOutputText().text()) + .findFirst() + .orElse("(none)"); + } + + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + ResponseEventStream stream = ResponseEventStream.create(responseContext, request) + .emitCreated() + .emitInProgress(); + + EXECUTOR_SERVICE.execute(() -> { + try { + // Resolve conversation history from previous responses. + // Returns empty list if no previous_response_id is set. + List history = responseContext.getHistoryAsync().join(); + + List messageHistory = history.stream() + .filter(ResponseItem::isResponseOutputMessage) + .map(ResponseItem::asResponseOutputMessage) + .toList(); + + String currentInput = request.inputText(); + + String reply; + if (messageHistory.isEmpty()) { + reply = "Welcome! I'm your study tutor. You asked: \"" + currentInput + "\". " + + "Let me help you understand that topic."; + } else { + // Find the last message in history + String lastText = extractMessageText(messageHistory.getLast()); + + String truncatedLastText = lastText.length() > 50 + ? lastText.substring(0, 50) + "..." + : lastText + "..."; + + reply = "[Turn " + messageHistory.size() + "] Building on our previous discussion " + + "(last answer: \"" + truncatedLastText + "\"), " + + "you asked: \"" + currentInput + "\"."; + } + + stream.addOutputMessage(msg -> msg.outputItemMessage(reply)); + stream.emitCompleted(); + } catch (Exception e) { + stream.emitFailed(); + } + }); + + return stream; + } + +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample6/MathSolverHandler.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample6/MathSolverHandler.java new file mode 100644 index 000000000000..ea24a2ef20ba --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample6/MathSolverHandler.java @@ -0,0 +1,64 @@ +package com.microsoft.agentserver.sample6; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; +import com.openai.models.responses.ResponseReasoningItem; + +import java.util.List; +import java.util.concurrent.Executors; + +/** + * A math solver handler demonstrating multi-output: reasoning + text message. + * Uses the convenience API to emit a reasoning item followed by a text answer. + * Equivalent to the C# MathSolverHandler (convenience version). + */ +public class MathSolverHandler implements ResponseHandler { + + public static final java.util.concurrent.ExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadExecutor(); + + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + String question = request.inputText(); + + ResponseEventStream stream = ResponseEventStream.create(responseContext, request) + .emitCreated() + .emitInProgress(); + + EXECUTOR_SERVICE.execute(() -> { + try { + // Output item 0: Reasoning — show the thought process. + String thought = "The user asked: \"" + question + "\". " + + "I need to identify the mathematical operation, " + + "compute the result, and explain the steps."; + + ResponseReasoningItem reasoningItem = ResponseReasoningItem.builder() + .id("reasoning_0") + .summary(List.of(ResponseReasoningItem.Summary.builder() + .text(thought) + .build())) + .status(ResponseReasoningItem.Status.COMPLETED) + .build(); + + stream.addOutputReasoningItem(reasoning -> reasoning + .emitAdded(reasoningItem) + .emitDone(reasoningItem)); + + // Output item 1: Message — the final answer. + String answer = "The answer is 42. Here's how: " + + "6 × 7 = 42. The multiplication of 6 and 7 gives 42."; + + stream.addOutputMessage(msg -> msg.outputItemMessage(answer)); + + stream.emitCompleted(); + } catch (Exception e) { + stream.emitFailed(); + } + }); + + return stream; + } + +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample6/MathSolverHandlerFullControl.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample6/MathSolverHandlerFullControl.java new file mode 100644 index 000000000000..e1ad1c39abe4 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample6/MathSolverHandlerFullControl.java @@ -0,0 +1,81 @@ +package com.microsoft.agentserver.sample6; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; +import com.openai.models.responses.ResponseReasoningItem; + +import java.util.List; +import java.util.concurrent.Executors; + +/** + * A math solver handler with full control over each individual SSE event. + * Demonstrates emitting reasoning summary deltas and text message deltas separately. + * Equivalent to the C# MathSolverHandlerFullControl. + */ +public class MathSolverHandlerFullControl implements ResponseHandler { + + public static final java.util.concurrent.ExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadExecutor(); + + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + String question = request.inputText(); + + ResponseEventStream stream = ResponseEventStream.create(responseContext, request) + .emitCreated() + .emitInProgress(); + + EXECUTOR_SERVICE.execute(() -> { + try { + // Output item 0: Reasoning — show the thought process. + String thought = "The user asked: \"" + question + "\". " + + "I need to identify the mathematical operation, " + + "compute the result, and explain the steps."; + + ResponseReasoningItem inProgressReasoning = ResponseReasoningItem.builder() + .id("reasoning_0") + .summary(List.of()) + .status(ResponseReasoningItem.Status.IN_PROGRESS) + .build(); + + ResponseReasoningItem completedReasoning = ResponseReasoningItem.builder() + .id("reasoning_0") + .summary(List.of(ResponseReasoningItem.Summary.builder() + .text(thought) + .build())) + .status(ResponseReasoningItem.Status.COMPLETED) + .build(); + + stream.addOutputReasoningItem(reasoning -> reasoning + .emitAdded(inProgressReasoning) + .emitDone(completedReasoning)); + + // Output item 1: Message — the final answer with full event control. + String answer = "The answer is 42. Here's how: " + + "6 × 7 = 42. The multiplication of 6 and 7 gives 42."; + + stream.addOutputMessage(msg -> { + msg.emitAdded(); // response.output_item.added + + msg.addTextPart(text -> { + text.emitAdded(); // response.content_part.added + text.emitDelta(answer); // response.output_text.delta + text.emitDone(answer); // response.output_text.done + // response.content_part.done (auto) + }); + + msg.emitDone(); // response.output_item.done + }); + + stream.emitCompleted(); + } catch (Exception e) { + stream.emitFailed(); + } + }); + + return stream; + } + +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample6/Sample6Snippets.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample6/Sample6Snippets.java new file mode 100644 index 000000000000..f1bcd2d652dd --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/java/com/microsoft/agentserver/sample6/Sample6Snippets.java @@ -0,0 +1,21 @@ +package com.microsoft.agentserver.sample6; + +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; + +/** + * Sample 6: Multi-output — starts a server with the MathSolverHandler. + * Demonstrates emitting reasoning + text message as multiple output items. + */ +public class Sample6Snippets { + public static void main(String[] args) throws InterruptedException { + JerseyAgentServerAdaptorService.buildAgent( + ResponsesApi.create( + new MathSolverHandler() + ) + ); + + Thread.sleep(Long.MAX_VALUE); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/resources/log4j2.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/resources/log4j2.xml new file mode 100644 index 000000000000..ea83e52415fd --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-doc-samples/src/main/resources/log4j2.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/Dockerfile b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/Dockerfile new file mode 100644 index 000000000000..66f46ce7c217 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/Dockerfile @@ -0,0 +1,14 @@ +FROM eclipse-temurin:25-jre + +WORKDIR /app + +# Download the Application Insights Java agent +ADD https://github.com/microsoft/ApplicationInsights-Java/releases/download/3.6.2/applicationinsights-agent-3.6.2.jar applicationinsights-agent.jar + +COPY target/azure-agentserver-echo-sample-*-jar-with-dependencies.jar app.jar +COPY applicationinsights.json applicationinsights.json + +EXPOSE 8088 + +ENTRYPOINT ["sh", "-c", "exec java ${APPLICATIONINSIGHTS_CONNECTION_STRING:+-javaagent:/app/applicationinsights-agent.jar} -jar app.jar"] + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/applicationinsights.json b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/applicationinsights.json new file mode 100644 index 000000000000..8f728cd6192d --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/applicationinsights.json @@ -0,0 +1,14 @@ +{ + "sampling": { + "percentage": 100 + }, + "instrumentation": { + "logging": { + "level": "DEBUG" + } + }, + "selfDiagnostics": { + "level": "DEBUG" + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/docker-compose.yml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/docker-compose.yml new file mode 100644 index 000000000000..c8708ce7b86e --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/docker-compose.yml @@ -0,0 +1,15 @@ +services: + echo-sample: + build: + context: . + dockerfile: Dockerfile + image: ${REGISTRY:-}myagent:latest + ports: + - "8088:8088" + + env_file: + - ../../.env + + environment: + - APPLICATIONINSIGHTS_CONNECTION_STRING=${APPLICATIONINSIGHTS_CONNECTION_STRING} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/pom.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/pom.xml new file mode 100644 index 000000000000..bef49d8bf640 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + azure-agentserver-echo-sample + azure-agentserver-echo-sample + Simple echo agent sample for the Azure AI Foundry Agent Server Server + + + + com.microsoft.agentserver + azure-agentserver-api + + + com.microsoft.agentserver + azure-agentserver-api-jaxrs + + + com.microsoft.agentserver + azure-agentserver-jersey + compile + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + ${maven-assembly-plugin.version} + + + + com.microsoft.agentserver.Main + + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/run-sample.sh b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/run-sample.sh new file mode 100755 index 000000000000..d1234a3a0194 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/run-sample.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +../../mvnw clean package -DskipTests + +export REGISTRY=${REGISTRY:-} + +docker compose build +docker compose up --force-recreate -d + +# wait for the container to be ready +sleep 5 + +curl -vvv -X POST http://localhost:8088/responses \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Echo this back to me.", + "model": "gpt-4o" + }' -o - | json_pp + +sleep 1 + +curl -vvv -X POST http://localhost:8088/responses \ + -H "Accept: text/event-stream" \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Echo this back to me.", + "model": "gpt-4o", + "stream": true + }' -o - + +docker-compose down diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/src/main/java/com/microsoft/agentserver/EchoHandler.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/src/main/java/com/microsoft/agentserver/EchoHandler.java new file mode 100644 index 000000000000..a149efb47c71 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/src/main/java/com/microsoft/agentserver/EchoHandler.java @@ -0,0 +1,90 @@ +package com.microsoft.agentserver; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.CreateResponse; +import com.microsoft.agentserver.api.ResponseBuilder; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; +import com.openai.models.responses.ResponseOutputText; + +import java.util.ArrayList; +import java.util.concurrent.Executors; + +public class EchoHandler implements ResponseHandler { + @Override + public CreateResponse createResponse( + ResponseContext responseContext, + AgentServerCreateResponse request) { + + String text = request.inputText(); + if (!text.isEmpty()) { + ResponseOutputText responseOutputText = ResponseOutputText.builder() + .text(text) + .annotations(new ArrayList<>()) + .build(); + + return new CreateResponse( + request.agent(), + ResponseBuilder.convertOutputToResponse(request, responseOutputText) + ); + } + + throw new IllegalArgumentException("No text input provided in the request"); + } + + + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + // Simulate asynchronous response generation with multiple updates to the same message over time. + ResponseEventStream stream = ResponseEventStream.create(responseContext, request) + .emitCreated() + .emitInProgress(); + Executors + .newSingleThreadExecutor() + .execute(() -> { + try { + stream + .addOutputMessage(msg -> msg + .emitAdded() + .addTextPart(text -> { + text = text + .emitAdded() + .emitDelta("Hello from"); + + text.emitDelta(" the echo "); + + try { + Thread.sleep(5000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + text.emitDelta("handler!") + .emitDone("Hello from the echo handler!"); + }) + .addTextPart(text -> { + text = text + .emitAdded() + .emitDelta("Hello from"); + + text.emitDelta(" the second echo "); + + try { + Thread.sleep(5000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + text.emitDelta("handler!") + .emitDone("Hello from the second echo handler!"); + }) + .emitDone()); + } finally { + stream.emitCompleted(); + } + }); + + return stream; + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/src/main/java/com/microsoft/agentserver/Main.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/src/main/java/com/microsoft/agentserver/Main.java new file mode 100644 index 000000000000..68ba268e4fc4 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/src/main/java/com/microsoft/agentserver/Main.java @@ -0,0 +1,16 @@ +package com.microsoft.agentserver; + +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; + +public class Main { + public static void main(String[] args) throws InterruptedException { + JerseyAgentServerAdaptorService.buildAgent( + ResponsesApi.builder() + .responseHandler(new EchoHandler()) + .build() + ); + + Thread.sleep(Long.MAX_VALUE); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/src/main/java/com/microsoft/agentserver/WeatherHandler.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/src/main/java/com/microsoft/agentserver/WeatherHandler.java new file mode 100644 index 000000000000..1506a7ebd2a7 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-echo-sample/src/main/java/com/microsoft/agentserver/WeatherHandler.java @@ -0,0 +1,86 @@ +package com.microsoft.agentserver; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseInputItem; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Executors; + +public class WeatherHandler implements ResponseHandler { + + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + ResponseEventStream stream = ResponseEventStream.create(responseContext, request); + + Executors.newSingleThreadExecutor().execute(() -> { + try { + // Check if the input contains a function call output (turn 2) + ResponseInputItem.FunctionCallOutput toolOutput = findFunctionCallOutput(request.responseCreateParams()); + + if (toolOutput != null) { + // Turn 2: function output received — return the weather as a text message + String weatherJson = toolOutput.output().isString() + ? toolOutput.output().asString() + : "{}"; + + stream.emitCreated(); + stream.emitInProgress(); + + stream.addOutputMessage(msg -> { + msg.emitAdded(); + msg.addTextPart(text -> { + text.emitAdded(); + String reply = "The weather is: " + weatherJson; + text.emitDelta(reply); + text.emitDone(reply); + }); + msg.emitDone(); + }); + + stream.emitCompleted(); + } else { + // Turn 1: emit a function call for "get_weather" + stream.emitCreated(); + stream.emitInProgress(); + + String arguments = "{\"location\":\"Seattle\",\"unit\":\"fahrenheit\"}"; + + stream.addOutputFunctionCall(funcCall -> funcCall + .emitAdded("get_weather", "call_weather_1") + .emitArgumentsDelta(arguments) + .emitArgumentsDone("get_weather", arguments) + .emitDone()); + + stream.emitCompleted(); + } + } catch (Exception e) { + stream.emitFailed(); + } + }); + + return stream; + } + + private static ResponseInputItem.FunctionCallOutput findFunctionCallOutput(ResponseCreateParams.Body request) { + Optional inputOpt = request.input(); + if (inputOpt.isEmpty()) { + return null; + } + ResponseCreateParams.Input input = inputOpt.get(); + if (!input.isResponse()) { + return null; + } + List items = input.asResponse(); + for (ResponseInputItem item : items) { + if (item.isFunctionCallOutput()) { + return item.asFunctionCallOutput(); + } + } + return null; + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests/pom.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests/pom.xml new file mode 100644 index 000000000000..ab36951c3050 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../../../pom.xml + + + azure-agentserver-integration-tests + azure-agentserver-integration-tests + Integration tests for the Azure AI Foundry Agent Server Server + + + 21 + 21 + UTF-8 + + + + + org.slf4j + slf4j-api + + + + org.glassfish.jersey.containers + jersey-container-grizzly2-http + compile + + + org.glassfish.jersey.inject + jersey-hk2 + compile + + + org.glassfish.jersey.media + jersey-media-json-jackson + compile + + + org.glassfish.jersey.media + jersey-media-sse + compile + + + com.microsoft.agentserver + azure-agentserver-api-jaxrs + + + com.microsoft.agentserver + azure-agentserver-api + + + jakarta.enterprise + jakarta.enterprise.cdi-api + + + + + org.junit.jupiter + junit-jupiter + test + + + org.wiremock + wiremock-standalone + test + + + org.mockito + mockito-core + test + + + org.glassfish.jersey.test-framework.providers + jersey-test-framework-provider-grizzly2 + test + + + org.slf4j + slf4j-simple + test + + + com.openai + openai-java + test + + + com.microsoft.agentserver + azure-agentserver-langchain4j-bom + pom + test + + + com.microsoft.agentserver + azure-agentserver-langchain4j-financial-sample + test + + + com.microsoft.agentserver + azure-agentserver-langchain4j-financial-sample + test + + + com.microsoft.agentserver + azure-agentserver-jersey + test + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests/src/test/java/UpstreamOpenAISmokeTest.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests/src/test/java/UpstreamOpenAISmokeTest.java new file mode 100644 index 000000000000..fac746455998 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests/src/test/java/UpstreamOpenAISmokeTest.java @@ -0,0 +1,524 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.api.ResponsesProvider; +import com.microsoft.agentserver.api.langchain4j.Langchain4jResponsesHandler; +import com.microsoft.agentserver.api.serialization.ObjectMapperFactory; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; +import dev.langchain4j.agentic.UntypedAgent; +import dev.langchain4j.agentic.scope.AgenticScope; +import dev.langchain4j.agentic.scope.ResultWithAgenticScope; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.model.azure.AzureOpenAiChatModel; +import org.glassfish.grizzly.http.server.HttpServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +/** + * Full end-to-end smoke test exercising the complete LangChain4j pipeline: + *

+ *   HTTP Client
+ *     → Jersey/Grizzly Server
+ *       → Langchain4jResponsesHandler
+ *         → AzureOpenAiChatModel (LangChain4j)
+ *           → WireMock (mocked Azure OpenAI endpoint)
+ * 
+ *

+ * WireMock mocks the Azure OpenAI Chat Completions API, returning streaming SSE + * responses. The LangChain4j handler converts the chat completion into the + * agent server the protocol and streams it back to the HTTP client. + */ +@Timeout(30) +class UpstreamOpenAISmokeTest { + + private static final ObjectMapper MAPPER = ObjectMapperFactory.getObjectMapper(); + + private static WireMockServer wireMock; + private static HttpServer agentServer; + private static String agentBaseUri; + + @BeforeAll + static void setUp() { + // 1. Start WireMock as the mock Azure OpenAI endpoint + wireMock = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); + wireMock.start(); + + // 2. Build a concrete UntypedAgent that calls WireMock's chat completions endpoint + // directly via HTTP. We bypass AzureOpenAiChatModel because: + // a) AgenticServices.agentBuilder creates a JDK proxy that fails with + // ClassCastException on generic return types in current beta + // b) AzureOpenAiChatModel requires HTTPS for key credentials + // The test still exercises: Jersey → Handler → Agent → WireMock (upstream mock) + UntypedAgent agent = new WireMockBackedAgent( + wireMock.baseUrl() + "/openai/deployments/gpt-4o/chat/completions"); + + // 3. Build the ResponsesApi using LangChain4j handler + ResponsesApi api = Langchain4jResponsesHandler.builder() + .agent(agent) + .provider(ResponsesProvider.inMemory()) + .build(); + + // 4. Boot the Jersey/Grizzly agent server + agentBaseUri = "http://localhost:19877"; + agentServer = JerseyAgentServerAdaptorService.buildAgent(agentBaseUri, api); + } + + @AfterAll + static void tearDown() { + if (agentServer != null) agentServer.shutdownNow(); + if (wireMock != null) wireMock.stop(); + } + + @BeforeEach + void resetWireMock() { + wireMock.resetAll(); + } + + // ── WireMock stubs for Azure OpenAI Chat Completions ──────── + + /** + * Stubs the Azure OpenAI chat completions endpoint to return a non-streaming JSON response. + * Azure OpenAI uses: POST /openai/deployments/{deployment}/chat/completions?api-version=... + */ + private void stubAzureOpenAIChatCompletion(String responseText) { + String responseBody = """ + { + "id": "chatcmpl-mock-123", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4o", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "%s" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30 + } + } + """.formatted(responseText.replace("\"", "\\\"")); + + wireMock.stubFor(WireMock.post(WireMock.urlPathMatching("/openai/deployments/.*/chat/completions")) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(responseBody))); + } + + /** + * Stubs the Azure OpenAI chat completions endpoint to return + * a streaming SSE response (chunked token-by-token). + */ + private void stubAzureOpenAIStreamingChatCompletion(String responseText) { + StringBuilder sseBody = new StringBuilder(); + String[] tokens = responseText.split("(?<=\\s)"); + + for (String token : tokens) { + String escaped = token.replace("\\", "\\\\").replace("\"", "\\\""); + sseBody.append("data: {\"id\":\"chatcmpl-mock\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"") + .append(escaped) + .append("\"},\"finish_reason\":null}]}\n\n"); + } + + // Final chunk with finish_reason + sseBody.append("data: {\"id\":\"chatcmpl-mock\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n"); + sseBody.append("data: [DONE]\n\n"); + + wireMock.stubFor(WireMock.post(WireMock.urlPathMatching("/openai/deployments/.*/chat/completions")) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(sseBody.toString()))); + } + + // ── HTTP helpers ───────────────────────────────────────────── + + record TestResponse(int statusCode, String body, Map> headers) { + } + + private TestResponse post(String path, String jsonBody) throws Exception { + HttpURLConnection conn = (HttpURLConnection) URI.create(agentBaseUri + path).toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(15000); + conn.setDoOutput(true); + byte[] bodyBytes = jsonBody.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(bodyBytes.length); + try (var out = conn.getOutputStream()) { + out.write(bodyBytes); + out.flush(); + } + return readResponse(conn); + } + + private TestResponse get(String path) throws Exception { + HttpURLConnection conn = (HttpURLConnection) URI.create(agentBaseUri + path).toURL().openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(10000); + return readResponse(conn); + } + + private TestResponse readResponse(HttpURLConnection conn) throws Exception { + int status = conn.getResponseCode(); + String body; + try (InputStream in = (status >= 400 ? conn.getErrorStream() : conn.getInputStream())) { + body = in != null ? new String(in.readAllBytes(), StandardCharsets.UTF_8) : ""; + } + Map> headers = conn.getHeaderFields(); + conn.disconnect(); + return new TestResponse(status, body, headers); + } + + // ══════════════════════════════════════════════════════════════ + // E2E: Client → Jersey → LangChain4j → AzureOpenAI (WireMock) + // ══════════════════════════════════════════════════════════════ + + @Test + @DisplayName("E2E: Non-streaming request through AzureOpenAiChatModel returns correct response text") + void nonStreamingThroughAzureOpenAI() throws Exception { + String expectedText = "The capital of France is Paris."; + stubAzureOpenAIChatCompletion(expectedText); + + TestResponse response = post("/responses", + "{\"input\": \"What is the capital of France?\", \"model\": \"gpt-4o\"}"); + + // Debug: print all WireMock requests received + var allRequests = wireMock.findAll(com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor( + com.github.tomakehurst.wiremock.client.WireMock.anyUrl())); + System.out.println("WireMock received " + allRequests.size() + " requests:"); + for (var req : allRequests) { + System.out.println(" " + req.getMethod() + " " + req.getUrl()); + } + + Assertions.assertEquals(200, response.statusCode(), "Response body: " + response.body()); + + JsonNode json = MAPPER.readTree(response.body()); + Assertions.assertTrue(json.has("id"), "Response should have an id"); + Assertions.assertTrue(json.get("id").asText().startsWith("caresp_"), "ID should start with caresp_"); + Assertions.assertTrue(json.has("output"), "Response should have output"); + + // Extract text from output message + JsonNode output = json.get("output"); + Assertions.assertTrue(output.isArray() && !output.isEmpty(), "Output should be non-empty array"); + JsonNode firstOutput = output.get(0); + Assertions.assertTrue(firstOutput.has("content"), "Output item should have content"); + JsonNode content = firstOutput.get("content"); + Assertions.assertTrue(content.isArray() && !content.isEmpty()); + String actualText = content.get(0).get("text").asText(); + Assertions.assertEquals(expectedText, actualText, + "Response text should match the mocked Azure OpenAI response"); + + // Verify WireMock received the upstream call + wireMock.verify(WireMock.postRequestedFor(WireMock.urlPathMatching("/openai/deployments/.*/chat/completions"))); + } + + @Test + @DisplayName("E2E: Streaming request through AzureOpenAiChatModel returns SSE events") + void streamingThroughAzureOpenAI() throws Exception { + String expectedText = "Hello from Azure OpenAI!"; + stubAzureOpenAIChatCompletion(expectedText); + + // Send streaming request + HttpURLConnection conn = (HttpURLConnection) URI.create(agentBaseUri + "/responses").toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Accept", "text/event-stream"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(15000); + conn.setDoOutput(true); + String body = "{\"input\": \"Say hello\", \"model\": \"gpt-4o\", \"stream\": true}"; + byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(bodyBytes.length); + try (var out = conn.getOutputStream()) { + out.write(bodyBytes); + out.flush(); + } + + Assertions.assertEquals(200, conn.getResponseCode()); + + // Read SSE events + List eventNames = new java.util.ArrayList<>(); + List eventDataList = new java.util.ArrayList<>(); + + try (var reader = new java.io.BufferedReader( + new java.io.InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + String currentEvent = null; + while ((line = reader.readLine()) != null) { + if (line.startsWith("event:")) { + currentEvent = line.substring(6).trim(); + } else if (line.startsWith("data:")) { + String data = line.substring(5).trim(); + if (currentEvent != null) { + eventNames.add(currentEvent); + eventDataList.add(data); + currentEvent = null; + } + } + } + } + conn.disconnect(); + + // Verify SSE lifecycle + Assertions.assertFalse(eventNames.isEmpty(), "Should have received SSE events"); + Assertions.assertTrue(eventNames.contains("response.created"), + "Missing response.created. Got: " + eventNames); + Assertions.assertTrue(eventNames.stream().anyMatch(e -> e.contains("completed") || e.contains("failed")), + "Missing terminal event. Got: " + eventNames); + + // Verify text content was streamed + StringBuilder assembledText = new StringBuilder(); + for (int i = 0; i < eventNames.size(); i++) { + if ("response.output_text.delta".equals(eventNames.get(i))) { + JsonNode deltaNode = MAPPER.readTree(eventDataList.get(i)); + if (deltaNode.has("delta")) { + assembledText.append(deltaNode.get("delta").asText()); + } + } + } + Assertions.assertEquals(expectedText, assembledText.toString(), + "Assembled text deltas should match the mocked Azure OpenAI response"); + + // Verify upstream was called + wireMock.verify(WireMock.postRequestedFor(WireMock.urlPathMatching("/openai/deployments/.*/chat/completions"))); + } + + @Test + @DisplayName("E2E: Azure OpenAI returns error → agent server returns failure") + void upstreamErrorProducesFailure() throws Exception { + wireMock.stubFor(WireMock.post(WireMock.urlPathMatching("/openai/deployments/.*/chat/completions")) + .willReturn(WireMock.aResponse() + .withStatus(429) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\":{\"message\":\"Rate limit exceeded\",\"type\":\"rate_limit_error\",\"code\":\"429\"}}"))); + + TestResponse response = post("/responses", + "{\"input\": \"This should fail\", \"model\": \"gpt-4o\"}"); + + // Should get 500 (internal server error) since the handler catches the upstream error + Assertions.assertTrue(response.statusCode() == 500 || response.statusCode() == 200, + "Expected 500 or 200 with error, got: " + response.statusCode()); + + wireMock.verify(WireMock.postRequestedFor(WireMock.urlPathMatching("/openai/deployments/.*/chat/completions"))); + } + + @Test + @DisplayName("E2E: Upstream request contains the user's input message") + void upstreamRequestContainsUserInput() throws Exception { + stubAzureOpenAIChatCompletion("Test response"); + + post("/responses", + "{\"input\": \"Tell me about quantum computing\", \"model\": \"gpt-4o\"}"); + + // Verify the upstream request content + var requests = wireMock.findAll( + WireMock.postRequestedFor(WireMock.urlPathMatching("/openai/deployments/.*/chat/completions"))); + Assertions.assertFalse(requests.isEmpty(), "Should have made an upstream request"); + + String requestBody = requests.get(requests.size() - 1).getBodyAsString(); + JsonNode requestJson = new ObjectMapper().readTree(requestBody); + + // LangChain4j sends chat completions format with messages array + Assertions.assertTrue(requestJson.has("messages"), "Request should have messages array"); + JsonNode messages = requestJson.get("messages"); + Assertions.assertTrue(messages.isArray() && !messages.isEmpty()); + + // Find the user message + boolean foundUserMessage = false; + for (JsonNode msg : messages) { + if ("user".equals(msg.get("role").asText())) { + Assertions.assertTrue(msg.get("content").asText().contains("quantum computing"), + "User message should contain the input text"); + foundUserMessage = true; + } + } + Assertions.assertTrue(foundUserMessage, "Should have a user message in upstream request"); + } + + @Test + @DisplayName("E2E: Health endpoints still work alongside LangChain4j handler") + void healthEndpointsWork() throws Exception { + Assertions.assertEquals(200, get("/readiness").statusCode()); + Assertions.assertEquals(200, get("/liveness").statusCode()); + } + + @Test + @DisplayName("E2E: Full CRUD lifecycle with LangChain4j handler") + void fullCrudLifecycle() throws Exception { + stubAzureOpenAIChatCompletion("CRUD test response"); + + // Create + TestResponse createResp = post("/responses", + "{\"input\": \"Test CRUD\", \"model\": \"gpt-4o\"}"); + Assertions.assertEquals(200, createResp.statusCode(), "Body: " + createResp.body()); + String responseId = MAPPER.readTree(createResp.body()).get("id").asText(); + + // Get + TestResponse getResp = get("/responses/" + responseId); + Assertions.assertEquals(200, getResp.statusCode()); + Assertions.assertEquals(responseId, MAPPER.readTree(getResp.body()).get("id").asText()); + + // List input items + TestResponse itemsResp = get("/responses/" + responseId + "/input_items"); + Assertions.assertEquals(200, itemsResp.statusCode()); + + // Delete (: 200 with deleted-object body) + HttpURLConnection conn = (HttpURLConnection) URI.create(agentBaseUri + "/responses/" + responseId).toURL().openConnection(); + conn.setRequestMethod("DELETE"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(10000); + Assertions.assertEquals(200, conn.getResponseCode()); + conn.disconnect(); + + // Verify gone + Assertions.assertEquals(404, get("/responses/" + responseId).statusCode()); + } + + // ── Test helper: concrete UntypedAgent calling WireMock directly ── + + /** + * Concrete {@link UntypedAgent} that calls WireMock's chat completions endpoint + * directly via HTTP, simulating what {@link AzureOpenAiChatModel} would do. + *

+ * This bypasses two issues in the current beta: + *

    + *
  • {@code AgenticServices.agentBuilder()} creates a JDK proxy that fails with + * {@code ClassCastException} on generic return types
  • + *
  • {@code AzureOpenAiChatModel} requires HTTPS for key credentials
  • + *
+ * The test still exercises the full agent server pipeline: + * HTTP Client → Jersey → Langchain4jResponsesHandler → this agent → WireMock + */ + static class WireMockBackedAgent implements UntypedAgent { + private static final ObjectMapper OM = new ObjectMapper(); + private final String chatCompletionsUrl; + + WireMockBackedAgent(String chatCompletionsUrl) { + this.chatCompletionsUrl = chatCompletionsUrl; + } + + @Override + @SuppressWarnings("unchecked") + public Object invoke(Map input) { + return invokeWithAgenticScope(input).result(); + } + + @Override + @SuppressWarnings("unchecked") + public ResultWithAgenticScope invokeWithAgenticScope(Map input) { + try { + List messages = (List) input.get("messages"); + + // Build a chat completions request in Azure OpenAI format + List> msgArray = new java.util.ArrayList<>(); + for (ChatMessage msg : messages) { + String role; + String content; + if (msg instanceof dev.langchain4j.data.message.UserMessage um) { + role = "user"; + content = um.singleText(); + } else if (msg instanceof dev.langchain4j.data.message.SystemMessage sm) { + role = "system"; + content = sm.text(); + } else if (msg instanceof AiMessage am) { + role = "assistant"; + content = am.text(); + } else { + continue; + } + msgArray.add(Map.of("role", role, "content", content)); + } + + String requestJson = OM.writeValueAsString(Map.of("messages", msgArray, "model", "gpt-4o")); + + // POST to WireMock + HttpURLConnection conn = (HttpURLConnection) URI.create(chatCompletionsUrl) + .toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("api-key", "test-api-key"); + conn.setDoOutput(true); + conn.setConnectTimeout(5000); + conn.setReadTimeout(10000); + byte[] body = requestJson.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(body.length); + try (var out = conn.getOutputStream()) { + out.write(body); + } + + int status = conn.getResponseCode(); + if (status >= 400) { + String errorBody; + try (InputStream err = conn.getErrorStream()) { + errorBody = err != null ? new String(err.readAllBytes(), StandardCharsets.UTF_8) : ""; + } + conn.disconnect(); + throw new RuntimeException("Upstream error " + status + ": " + errorBody); + } + + String responseBody; + try (InputStream in = conn.getInputStream()) { + responseBody = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + conn.disconnect(); + + // Parse chat completion response + JsonNode json = OM.readTree(responseBody); + String text = json.get("choices").get(0).get("message").get("content").asText(); + return new ResultWithAgenticScope<>(null, text); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Failed to call upstream", e); + } + } + + @Override + public AgenticScope getAgenticScope(Object memoryId) { + return null; + } + + @Override + public boolean evictAgenticScope(Object memoryId) { + return false; + } + } +} + + + + + + + + + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests/src/test/java/financial/FinancialAgentSmokeTest.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests/src/test/java/financial/FinancialAgentSmokeTest.java new file mode 100644 index 000000000000..9f9c620fbcf1 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests/src/test/java/financial/FinancialAgentSmokeTest.java @@ -0,0 +1,600 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package financial; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.agentserver.api.ResponsesProvider; +import com.microsoft.agentserver.api.langchain4j.Langchain4jResponsesHandler; +import com.microsoft.agentserver.api.langchain4j.SupervisorAgentWithMemory; +import com.microsoft.agentserver.api.serialization.ObjectMapperFactory; +import com.microsoft.agentserver.sample.financial.AccountingAgent; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.ToolExecutionResultMessage; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.output.FinishReason; +import dev.langchain4j.model.output.TokenUsage; +import org.glassfish.grizzly.http.server.HttpServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.Timeout; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * End-to-end smoke test for the financial supervisor agent sample, + * exercising the full agent server pipeline with a Mockito-mocked + * {@link ChatModel}: + *
+ *   HTTP Client
+ *     → Jersey/Grizzly Server
+ *       → Langchain4jResponsesHandler (supervisor path)
+ *         → SupervisorAgent (LangChain4j agentic)
+ *           → Planner (→ mocked ChatModel returning AgentInvocation JSON)
+ *           → Sub-agents (withdraw$0, credit$1, exchange$2, getBalance$3)
+ *             → Mocked ChatModel (returning text or tool_calls)
+ *           → BankTool / ExchangeTool (real local execution)
+ * 
+ *

+ * The supervisor planner expects JSON responses in iterative steps: + *

    + *
  1. Step 1: route to a sub-agent → {@code {"agentName": "getBalance$3", "arguments": {"user": "Mario"}}}
  2. + *
  3. Step 2: after sub-agent result, return "done" → {@code {"agentName": "done", "arguments": {"response": "..."}}}
  4. + *
+ * Sub-agents use the same ChatModel for tool calls and text responses. + *

+ * Available sub-agent names (registered by AgenticServices): + *

    + *
  • {@code withdraw$0} — A banker that withdraws USD from an account [user: String, amount: Double]
  • + *
  • {@code credit$1} — A banker that credits USD to an account [user: String, amount: Double]
  • + *
  • {@code exchange$2} — Currency exchange [originalCurrency: String, amount: Double, targetCurrency: String]
  • + *
  • {@code getBalance$3} — Account info [user: String]
  • + *
+ */ +@Timeout(30) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class FinancialAgentSmokeTest { + + private static final ObjectMapper MAPPER = ObjectMapperFactory.getObjectMapper(); + private static final String BASE_URI = "http://localhost:19878"; + + private static HttpServer agentServer; + private static ChatModel mockModel; + + @BeforeAll + static void setUp() { + mockModel = mock(ChatModel.class); + + // Default stub so the supervisor can be built without errors + when(mockModel.chat(any(ChatRequest.class))) + .thenReturn(textResponse("{\"agentName\": \"done\", \"arguments\": {\"response\": \"default\"}}")); + + SupervisorAgentWithMemory agent = AccountingAgent.build(mockModel); + + agentServer = JerseyAgentServerAdaptorService.buildAgent( + BASE_URI, + Langchain4jResponsesHandler.builder() + .supervisorAgent(agent) + .provider(ResponsesProvider.inMemory()) + .build()); + } + + @AfterAll + static void tearDown() { + if (agentServer != null) agentServer.shutdownNow(); + } + + // ── Mock helpers ───────────────────────────────────────────── + + /** + * Detects whether the current call is a planner call by checking for the + * planner's characteristic system message. + */ + private static boolean isPlannerCall(ChatRequest request) { + String systemText = extractSystemMessage(request); + return systemText != null && systemText.contains("planner expert"); + } + + /** + * Detects whether the current call is a scorer call. The scorer has no system + * message and typically a single user message asking for scoring. + */ + private static boolean isScorerCall(ChatRequest request) { + String systemText = extractSystemMessage(request); + if (systemText != null && (systemText.contains("score") || systemText.contains("rating"))) { + return true; + } + // Scorer call has no system message, few messages, and no tool specs + if (systemText == null + && (request.toolSpecifications() == null || request.toolSpecifications().isEmpty())) { + return true; + } + return false; + } + + /** + * Configures the mock for a simple flow: route to a sub-agent, the sub-agent + * returns text directly (no tool calls), then planner returns "done". + *

+ * Flow: + * Planner call 1 → route to targetAgent with args + * Sub-agent call → return text + * Planner call 2 → return "done" with responseText + */ + private static void stubSimpleRouting(String targetAgent, String agentArgs, + String subAgentResponse, String finalResponse) { + AtomicInteger plannerCalls = new AtomicInteger(0); + + when(mockModel.chat(any(ChatRequest.class))).thenAnswer(invocation -> { + ChatRequest request = invocation.getArgument(0); + + if (isPlannerCall(request)) { + int call = plannerCalls.incrementAndGet(); + if (call == 1) { + // First planner call: route to the target agent + return textResponse(""" + {"agentName": "%s", "arguments": %s} + """.formatted(targetAgent, agentArgs)); + } else { + // Subsequent planner call: we're done + return textResponse(""" + {"agentName": "done", "arguments": {"response": "%s"}} + """.formatted(escapeJson(finalResponse))); + } + } + + if (isScorerCall(request)) { + return textResponse("{\"score1\": 0.9, \"score2\": 0.1}"); + } + + // Sub-agent call: return text + return textResponse(subAgentResponse); + }); + } + + /** + * Configures the mock for a tool-calling flow: route to a sub-agent, the + * sub-agent issues a tool call, tool executes locally, sub-agent returns + * text after tool result, then planner returns "done". + *

+ * Flow: + * Planner call 1 → route to targetAgent with args + * Sub-agent call 1 (has tool specs) → return tool_call + * [BankTool/ExchangeTool executes locally] + * Sub-agent call 2 (has tool result message) → return text + * Planner call 2 → return "done" with responseText + */ + private static void stubToolCallingFlow(String targetAgent, String agentArgs, + String toolName, String toolArgs, + String subAgentResponse, String finalResponse) { + AtomicInteger plannerCalls = new AtomicInteger(0); + AtomicInteger toolCallCount = new AtomicInteger(0); + + when(mockModel.chat(any(ChatRequest.class))).thenAnswer(invocation -> { + ChatRequest request = invocation.getArgument(0); + List messages = request.messages(); + + if (isPlannerCall(request)) { + int call = plannerCalls.incrementAndGet(); + if (call == 1) { + return textResponse(""" + {"agentName": "%s", "arguments": %s} + """.formatted(targetAgent, agentArgs)); + } else { + return textResponse(""" + {"agentName": "done", "arguments": {"response": "%s"}} + """.formatted(escapeJson(finalResponse))); + } + } + + if (isScorerCall(request)) { + return textResponse("{\"score1\": 0.9, \"score2\": 0.1}"); + } + + // Sub-agent: check if tool result is present (second call) + boolean hasToolResult = messages.stream() + .anyMatch(m -> m instanceof ToolExecutionResultMessage); + if (hasToolResult) { + return textResponse(subAgentResponse); + } + + // Sub-agent: if tools are available, issue a tool call (first call) + if (request.toolSpecifications() != null && !request.toolSpecifications().isEmpty()) { + toolCallCount.incrementAndGet(); + return toolCallResponse(toolName, toolArgs); + } + + // Fallback + return textResponse(subAgentResponse); + }); + } + + private static String extractSystemMessage(ChatRequest request) { + for (ChatMessage msg : request.messages()) { + if (msg instanceof SystemMessage sm) { + return sm.text().toLowerCase(); + } + } + return null; + } + + private static String escapeJson(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n"); + } + + private static ChatResponse textResponse(String text) { + return ChatResponse.builder() + .aiMessage(AiMessage.from(text)) + .tokenUsage(new TokenUsage(10, 20, 30)) + .finishReason(FinishReason.STOP) + .build(); + } + + private static ChatResponse toolCallResponse(String toolName, String argsJson) { + ToolExecutionRequest toolCall = ToolExecutionRequest.builder() + .id("call_" + toolName) + .name(toolName) + .arguments(argsJson) + .build(); + return ChatResponse.builder() + .aiMessage(AiMessage.from(List.of(toolCall))) + .tokenUsage(new TokenUsage(10, 5, 15)) + .finishReason(FinishReason.TOOL_EXECUTION) + .build(); + } + + // ── HTTP helpers ───────────────────────────────────────────── + + record TestResponse(int statusCode, String body, Map> headers) { + } + + private TestResponse post(String path, String jsonBody) throws Exception { + HttpURLConnection conn = (HttpURLConnection) URI.create(BASE_URI + path).toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(15000); + conn.setDoOutput(true); + byte[] bodyBytes = jsonBody.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(bodyBytes.length); + try (var out = conn.getOutputStream()) { + out.write(bodyBytes); + out.flush(); + } + return readResponse(conn); + } + + private TestResponse get(String path) throws Exception { + HttpURLConnection conn = (HttpURLConnection) URI.create(BASE_URI + path).toURL().openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(10000); + return readResponse(conn); + } + + private TestResponse delete(String path) throws Exception { + HttpURLConnection conn = (HttpURLConnection) URI.create(BASE_URI + path).toURL().openConnection(); + conn.setRequestMethod("DELETE"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(10000); + return readResponse(conn); + } + + private TestResponse readResponse(HttpURLConnection conn) throws Exception { + int status = conn.getResponseCode(); + String body; + try (InputStream in = (status >= 400 ? conn.getErrorStream() : conn.getInputStream())) { + body = in != null ? new String(in.readAllBytes(), StandardCharsets.UTF_8) : ""; + } + Map> headers = conn.getHeaderFields(); + conn.disconnect(); + return new TestResponse(status, body, headers); + } + + // ══════════════════════════════════════════════════════════════ + // Tests + // ══════════════════════════════════════════════════════════════ + + @Test + @Order(1) + @DisplayName("Non-streaming: simple text response through getBalance agent") + void nonStreamingSimpleTextResponse() throws Exception { + stubSimpleRouting("getBalance$3", "{\"user\": \"Mario\"}", + "1000.0", + "Mario's balance is $1000.00"); + + TestResponse response = post("/responses", + """ + {"input": "What is Mario's balance?", "model": "gpt-4o"} + """); + + Assertions.assertEquals(200, response.statusCode(), "Response body: " + response.body()); + + JsonNode json = MAPPER.readTree(response.body()); + Assertions.assertTrue(json.has("id"), "Response should have an id"); + Assertions.assertTrue(json.get("id").asText().startsWith("caresp_"), "ID should start with caresp_"); + Assertions.assertTrue(json.has("output"), "Response should have output"); + + JsonNode output = json.get("output"); + Assertions.assertTrue(output.isArray() && !output.isEmpty(), "Output should be non-empty array"); + JsonNode content = output.get(0).get("content"); + Assertions.assertTrue(content.isArray() && !content.isEmpty(), "Content should be non-empty array"); + + String actualText = content.get(0).get("text").asText(); + Assertions.assertFalse(actualText.isBlank(), "Response text should not be blank"); + } + + @Test + @Order(2) + @DisplayName("Non-streaming: tool call (getBalance) → tool execution → text response") + void nonStreamingWithToolExecution() throws Exception { + stubToolCallingFlow("getBalance$3", "{\"user\": \"Mario\"}", + "getBalance", "{\"user\": \"Mario\"}", + "1000.0", + "I checked Mario's account. His balance is 1000.0 USD."); + + TestResponse response = post("/responses", + """ + {"input": "What is Mario's account balance?", "model": "gpt-4o"} + """); + + Assertions.assertEquals(200, response.statusCode(), "Response body: " + response.body()); + + JsonNode json = MAPPER.readTree(response.body()); + JsonNode output = json.get("output"); + Assertions.assertTrue(output.isArray() && !output.isEmpty(), "Output should be non-empty array"); + + String text = output.get(0).get("content").get(0).get("text").asText(); + Assertions.assertFalse(text.isBlank(), "Response text should not be blank"); + } + + @Test + @Order(3) + @DisplayName("Streaming: returns SSE events with expected lifecycle") + void streamingReturnsSSEEvents() throws Exception { + stubSimpleRouting("getBalance$3", "{\"user\": \"Mario\"}", + "1000.0", + "Hello from the financial agent!"); + + HttpURLConnection conn = (HttpURLConnection) URI.create(BASE_URI + "/responses").toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Accept", "text/event-stream"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(15000); + conn.setDoOutput(true); + String body = """ + {"input": "Say hello", "model": "gpt-4o", "stream": true} + """; + byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(bodyBytes.length); + try (var out = conn.getOutputStream()) { + out.write(bodyBytes); + out.flush(); + } + + Assertions.assertEquals(200, conn.getResponseCode()); + + List eventNames = new ArrayList<>(); + List eventDataList = new ArrayList<>(); + + try (var reader = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + String currentEvent = null; + while ((line = reader.readLine()) != null) { + if (line.startsWith("event:")) { + currentEvent = line.substring(6).trim(); + } else if (line.startsWith("data:")) { + String data = line.substring(5).trim(); + if (currentEvent != null) { + eventNames.add(currentEvent); + eventDataList.add(data); + currentEvent = null; + } + } + } + } + conn.disconnect(); + + Assertions.assertFalse(eventNames.isEmpty(), "Should have received SSE events"); + Assertions.assertTrue(eventNames.contains("response.created"), + "Missing response.created. Got: " + eventNames); + Assertions.assertTrue( + eventNames.stream().anyMatch(e -> e.contains("completed") || e.contains("failed")), + "Missing terminal event. Got: " + eventNames); + } + + @Test + @Order(4) + @DisplayName("Streaming with tool call: getBalance tool executed and result streamed") + void streamingWithToolExecution() throws Exception { + stubToolCallingFlow("getBalance$3", "{\"user\": \"Georgio\"}", + "getBalance", "{\"user\": \"Georgio\"}", + "1000.0", + "Georgio's balance is 1000.0 USD"); + + HttpURLConnection conn = (HttpURLConnection) URI.create(BASE_URI + "/responses").toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Accept", "text/event-stream"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(15000); + conn.setDoOutput(true); + String body = """ + {"input": "What is Georgio's balance?", "model": "gpt-4o", "stream": true} + """; + byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(bodyBytes.length); + try (var out = conn.getOutputStream()) { + out.write(bodyBytes); + out.flush(); + } + + Assertions.assertEquals(200, conn.getResponseCode()); + + List eventNames = new ArrayList<>(); + try (var reader = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + String currentEvent = null; + while ((line = reader.readLine()) != null) { + if (line.startsWith("event:")) { + currentEvent = line.substring(6).trim(); + } else if (line.startsWith("data:")) { + if (currentEvent != null) { + eventNames.add(currentEvent); + currentEvent = null; + } + } + } + } + conn.disconnect(); + + Assertions.assertFalse(eventNames.isEmpty(), "Should have received SSE events"); + Assertions.assertTrue( + eventNames.stream().anyMatch(e -> e.contains("completed") || e.contains("failed")), + "Missing terminal event. Got: " + eventNames); + } + + @Test + @Order(5) + @DisplayName("CRUD lifecycle: create → get → list input items → delete → 404") + void fullCrudLifecycle() throws Exception { + stubSimpleRouting("getBalance$3", "{\"user\": \"Mario\"}", + "1000.0", + "CRUD test complete"); + + // Create + TestResponse createResp = post("/responses", + """ + {"input": "Test CRUD", "model": "gpt-4o"} + """); + Assertions.assertEquals(200, createResp.statusCode(), "Body: " + createResp.body()); + String responseId = MAPPER.readTree(createResp.body()).get("id").asText(); + Assertions.assertTrue(responseId.startsWith("caresp_"), "ID should start with caresp_"); + + // Get + TestResponse getResp = get("/responses/" + responseId); + Assertions.assertEquals(200, getResp.statusCode()); + Assertions.assertEquals(responseId, MAPPER.readTree(getResp.body()).get("id").asText()); + + // List input items + TestResponse itemsResp = get("/responses/" + responseId + "/input_items"); + Assertions.assertEquals(200, itemsResp.statusCode()); + + // Delete (: 200 with deleted-object body) + TestResponse deleteResp = delete("/responses/" + responseId); + Assertions.assertEquals(200, deleteResp.statusCode()); + Assertions.assertTrue(MAPPER.readTree(deleteResp.body()).get("deleted").asBoolean()); + + // Verify gone + Assertions.assertEquals(404, get("/responses/" + responseId).statusCode()); + } + + @Test + @Order(6) + @DisplayName("Health endpoints work alongside supervisor handler") + void healthEndpoints() throws Exception { + Assertions.assertEquals(200, get("/readiness").statusCode()); + Assertions.assertEquals(200, get("/liveness").statusCode()); + } + + @Test + @Order(7) + @DisplayName("Instructions field is forwarded as system message to the model") + void instructionsAreForwarded() throws Exception { + stubSimpleRouting("getBalance$3", "{\"user\": \"Mario\"}", + "1000.0", + "Responded with custom instructions"); + + TestResponse response = post("/responses", + """ + { + "input": "Hello", + "model": "gpt-4o", + "instructions": "You are a helpful financial assistant. Always respond in USD." + } + """); + + Assertions.assertEquals(200, response.statusCode(), "Response body: " + response.body()); + JsonNode json = MAPPER.readTree(response.body()); + Assertions.assertTrue(json.has("output"), "Response should have output"); + } + + @Test + @Order(8) + @DisplayName("Credit tool: supervisor routes to credit agent which executes credit tool") + void creditToolExecution() throws Exception { + stubToolCallingFlow("credit$1", "{\"user\": \"Mario\", \"amount\": 500.0}", + "credit", "{\"user\": \"Mario\", \"amount\": 500.0}", + "Successfully credited 500.0 to Mario. New balance: 1500.0 USD", + "Credited 500 USD to Mario. New balance is 1500.0 USD."); + + TestResponse response = post("/responses", + """ + {"input": "Credit 500 dollars to Mario's account", "model": "gpt-4o"} + """); + + Assertions.assertEquals(200, response.statusCode(), "Response body: " + response.body()); + + JsonNode json = MAPPER.readTree(response.body()); + String text = json.get("output").get(0).get("content").get(0).get("text").asText(); + Assertions.assertFalse(text.isBlank(), "Response should have text content"); + } + + @Test + @Order(9) + @DisplayName("Exchange tool: supervisor routes to exchange agent which executes exchange tool") + void exchangeToolExecution() throws Exception { + stubToolCallingFlow("exchange$2", + "{\"originalCurrency\": \"USD\", \"amount\": 100.0, \"targetCurrency\": \"EUR\"}", + "exchange", + "{\"originalCurrency\": \"USD\", \"amount\": 100.0, \"targetCurrency\": \"EUR\"}", + "90.0", + "Converted 100 USD to 90.0 EUR."); + + TestResponse response = post("/responses", + """ + {"input": "Convert 100 USD to EUR", "model": "gpt-4o"} + """); + + Assertions.assertEquals(200, response.statusCode(), "Response body: " + response.body()); + } + + @Test + @Order(10) + @DisplayName("Malformed JSON returns error status") + void malformedJsonReturnsError() throws Exception { + TestResponse response = post("/responses", "not valid json"); + + Assertions.assertTrue(response.statusCode() >= 400, + "Expected error status for malformed JSON, got: " + response.statusCode()); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j-bom/pom.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j-bom/pom.xml new file mode 100644 index 000000000000..75254555993b --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j-bom/pom.xml @@ -0,0 +1,99 @@ + + 4.0.0 + + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../../../pom.xml + + + azure-agentserver-langchain4j-bom + pom + azure-agentserver-langchain4j-bom + + Bill of Materials (BOM) for Azure AI Foundry Agent Server Server LangChain4j dependencies + + + + + io.netty + netty-bom + ${netty.version} + pom + import + + + + + + + com.microsoft.agentserver + azure-agentserver-langchain4j + ${project.version} + + + + com.azure + azure-core-http-netty + + + + + dev.langchain4j + langchain4j-azure-open-ai + + + com.openai + openai-java-core + + + dev.langchain4j + langchain4j-open-ai + + + dev.langchain4j + langchain4j + + + dev.langchain4j + langchain4j-agentic + + + + + io.netty + netty-handler + + + io.netty + netty-handler-proxy + + + io.netty + netty-buffer + + + io.netty + netty-codec + + + io.netty + netty-codec-http + + + io.netty + netty-codec-http2 + + + io.netty + netty-common + + + io.netty + netty-resolver-dns + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/pom.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/pom.xml new file mode 100644 index 000000000000..d99cc52292be --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/pom.xml @@ -0,0 +1,70 @@ + + 4.0.0 + + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../../../pom.xml + + + azure-agentserver-langchain4j + jar + azure-agentserver-langchain4j + + LangChain4j integration for the Azure AI Foundry Agent Server Server + + + + org.slf4j + slf4j-api + + + dev.langchain4j + langchain4j-agentic + compile + + + jakarta.enterprise + jakarta.enterprise.cdi-api + compile + + + com.openai + openai-java-core + + + com.fasterxml.jackson.core + * + + + com.fasterxml.jackson.datatype + * + + + + + com.microsoft.agentserver + azure-agentserver-api + compile + + + com.fasterxml.jackson.module + jackson-module-kotlin + compile + + + + com.openai + openai-java + test + + + org.junit.jupiter + junit-jupiter + test + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/Langchain4jResponsesHandler.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/Langchain4jResponsesHandler.java new file mode 100644 index 000000000000..3002df031074 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/Langchain4jResponsesHandler.java @@ -0,0 +1,481 @@ +package com.microsoft.agentserver.api.langchain4j; + +import com.microsoft.agentserver.api.ApiError; +import com.microsoft.agentserver.api.ApiException; +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.CreateResponse; +import com.microsoft.agentserver.api.ResponseBuilder; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.api.ResponsesProvider; +import com.microsoft.agentserver.api.langchain4j.noop.NOOPSupervisorAgent; +import com.microsoft.agentserver.api.langchain4j.noop.NOOPUntypedAgent; +import com.openai.models.responses.EasyInputMessage; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseInputContent; +import com.openai.models.responses.ResponseInputItem; +import com.openai.models.responses.ResponseInputMessageItem; +import com.openai.models.responses.ResponseItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseOutputText; +import dev.langchain4j.agentic.UntypedAgent; +import dev.langchain4j.agentic.scope.ResultWithAgenticScope; +import dev.langchain4j.agentic.supervisor.SupervisorAgent; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.Content; +import dev.langchain4j.data.message.ImageContent; +import dev.langchain4j.data.message.PdfFileContent; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.TextContent; +import dev.langchain4j.data.message.UserMessage; +import jakarta.inject.Inject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Langchain4j-specific {@link ResponseHandler} implementation. + *

+ * Handles converting OpenAI Responses API requests into langchain4j + * {@link dev.langchain4j.data.message.ChatMessage} format and invoking the + * configured agent ({@link UntypedAgent} or {@link SupervisorAgent}). + *

+ * Use the {@link #builder()} to obtain a ready-to-use {@link ResponsesApi} + * backed by a {@link ResponsesApi} that delegates to this handler. + */ +public class Langchain4jResponsesHandler implements ResponseHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(Langchain4jResponsesHandler.class); + + private final UntypedAgent agent; + private final SupervisorAgent supervisorAgent; + private final ExecutorService executorService = Executors.newCachedThreadPool(); + + @Inject + public Langchain4jResponsesHandler( + UntypedAgent agent, + SupervisorAgent supervisorAgent) { + this.agent = agent; + if (supervisorAgent != null && !(supervisorAgent instanceof NOOPSupervisorAgent)) { + this.supervisorAgent = supervisorAgent; + } else { + this.supervisorAgent = null; + } + } + + // --- ResponseHandler implementation --- + + @Override + public CreateResponse createResponse( + ResponseContext responseContext, + AgentServerCreateResponse request) { + try { + ResultWithAgenticScope result = validateAndInvoke(responseContext, request); + + ResponseOutputText responseOutputText = ResponseOutputText.builder() + .text(result.result()) + .annotations(new ArrayList<>()) + .build(); + + com.openai.models.responses.Response response = + ResponseBuilder.convertOutputToResponse(request, responseOutputText); + + return new CreateResponse(request.agent(), response); + } catch (ApiException e) { + throw new RuntimeException(e); + } + } + + @Override + public ResponseEventStream createAsync( + ResponseContext responseContext, + AgentServerCreateResponse request) { + + ResponseEventStream stream = ResponseEventStream.builder() + .context(responseContext) + .request(request) + .build(); + + executorService.execute(() -> { + try { + stream.awaitSubscription(); + stream.emitCreated(); + stream.emitInProgress(); + + ResultWithAgenticScope result = validateAndInvoke(responseContext, request); + String responseText = result.result(); + + //String[] tokens = responseText.split("(?<=\\s)|(?=\\s)"); + String[] tokens = new String[]{responseText}; + + stream.addOutputMessage(msg -> msg + .emitAdded() + .addTextPart(text -> { + text = text.emitAdded(); + for (String token : tokens) { + if (!token.isEmpty()) { + text = text.emitDelta(token); + } + } + text.emitDone(responseText); + }) + .emitDone()); + + stream.emitCompleted(); + } catch (Exception e) { + LOGGER.error("Error during streaming response", e); + stream.emitFailed(); + } + }); + + return stream; + } + + // --- Langchain4j-specific agent invocation --- + + /** + * Validates the request, loads conversation history from the ResponseContext, + * and invokes the agent with the converted input including history. + */ + public ResultWithAgenticScope validateAndInvoke(ResponseContext responseContext, AgentServerCreateResponse createResponse) throws ApiException { + ResponseCreateParams.Body responseCreateParams = getResponseCreateParams(createResponse); + + Map input = convert(responseCreateParams); + + // Load conversation history from the ResponseContext and prepend to messages + List messages = (List) input.get("messages"); + List historyMessages = loadHistoryMessages(responseContext); + if (!historyMessages.isEmpty()) { + LOGGER.info("Loaded {} history message(s) from conversation context", historyMessages.size()); + List combined = new ArrayList<>(historyMessages.size() + messages.size()); + // Separate system messages (instructions) from the rest + List systemMsgs = new ArrayList<>(); + List nonSystemMsgs = new ArrayList<>(); + for (ChatMessage msg : messages) { + if (msg instanceof SystemMessage) { + systemMsgs.add(msg); + } else { + nonSystemMsgs.add(msg); + } + } + // Order: system instructions, then history, then current turn input + combined.addAll(systemMsgs); + combined.addAll(historyMessages); + combined.addAll(nonSystemMsgs); + input.put("messages", combined); + } + + return invoke( + createResponse.responseCreateParams(), + input, + responseContext); + } + + /** + * Loads conversation history from the ResponseContext and converts to langchain4j messages. + * Uses {@link ResponseItem}'s union type to naturally determine roles: + * {@code ResponseInputMessageItem} carries explicit role (user/system/developer), + * {@code ResponseOutputMessage} is always assistant. + */ + private List loadHistoryMessages(ResponseContext responseContext) { + if (responseContext == null) { + return List.of(); + } + try { + List historyItems = responseContext.getHistoryAsync().join(); + if (historyItems == null || historyItems.isEmpty()) { + return List.of(); + } + List result = new ArrayList<>(); + for (ResponseItem item : historyItems) { + if (item.isResponseInputMessageItem()) { + // Input message with explicit role + ResponseInputMessageItem inputMsg = item.asResponseInputMessageItem(); + String text = extractTextFromInputContent(inputMsg.content()); + if (text != null && !text.isEmpty()) { + ResponseInputMessageItem.Role role = inputMsg.role(); + if (role.equals(ResponseInputMessageItem.Role.USER)) { + result.add(new UserMessage(text)); + } else if (role.equals(ResponseInputMessageItem.Role.SYSTEM) + || role.equals(ResponseInputMessageItem.Role.DEVELOPER)) { + result.add(new SystemMessage(text)); + } else { + result.add(new UserMessage(text)); + } + } + } else if (item.isResponseOutputMessage()) { + // Output message — always assistant + ResponseOutputMessage outputMsg = item.asResponseOutputMessage(); + String text = extractTextFromOutputMessage(outputMsg); + if (text != null && !text.isEmpty()) { + result.add(new AiMessage(text)); + } + } + // Skip tool calls, reasoning, etc. — not directly mappable to chat messages + } + return result; + } catch (Exception e) { + LOGGER.warn("Failed to load conversation history from context", e); + return List.of(); + } + } + + /** + * Extracts concatenated text from a list of {@link ResponseInputContent}. + */ + private String extractTextFromInputContent(List content) { + if (content == null || content.isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (ResponseInputContent c : content) { + if (c.isInputText()) { + if (!sb.isEmpty()) { + sb.append("\n"); + } + sb.append(c.asInputText().text()); + } + } + return sb.toString(); + } + + /** + * Extracts the concatenated text content from a ResponseOutputMessage. + */ + private String extractTextFromOutputMessage(ResponseOutputMessage message) { + if (message.content() == null || message.content().isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (ResponseOutputMessage.Content content : message.content()) { + if (content.isOutputText()) { + if (!sb.isEmpty()) { + sb.append("\n"); + } + sb.append(content.asOutputText().text()); + } + } + return sb.toString(); + } + + private static ResponseCreateParams.Body getResponseCreateParams(AgentServerCreateResponse createResponse) throws ApiException { + if (!createResponse.responseCreateParams().isValid()) { + throw new ApiException(400, ApiError.invalidRequest("Invalid request parameters")); + } + + return createResponse.responseCreateParams(); + } + + private ResultWithAgenticScope invoke(ResponseCreateParams.Body createResponse, Map input, ResponseContext responseContext) { + + boolean store = createResponse.store().orElse(true); + boolean useMemory = store && supervisorAgent instanceof SupervisorAgentWithMemory; + + if (supervisorAgent != null) { + if (useMemory) { + String id = resolveMemoryKey(createResponse, responseContext); + return ((SupervisorAgentWithMemory) supervisorAgent).invokeWithAgenticScope(id, input.get("messages").toString()); + } + return supervisorAgent.invokeWithAgenticScope(input.get("messages").toString()); + } else if (agent != null && !(agent instanceof NOOPUntypedAgent)) { + return agent.invokeWithAgenticScope(input); + } else { + throw new IllegalStateException("No agent or supervisorAgent available"); + } + } + + /** + * Resolves the memory partition key for a memory-aware supervisor invocation. + * Priority: explicit {@code previous_response_id} → {@code conversation.id} → + * the request-scoped session id resolved by {@code SessionIdResolver} + * (payload {@code agent_session_id} → {@code FOUNDRY_AGENT_SESSION_ID} env → + * SHA-256 derivation). Throws when none yields a non-empty value: a random + * fallback would silently disable memory by allocating a fresh per-request + * {@code ChatMemory} that is never reused or evicted. + */ + private static String resolveMemoryKey(ResponseCreateParams.Body createResponse, ResponseContext responseContext) { + if (createResponse.previousResponseId().isPresent()) { + String prev = createResponse.previousResponseId().get(); + if (!prev.isEmpty()) { + return prev; + } + } + if (createResponse.conversation().isPresent()) { + var conv = createResponse.conversation().get(); + if (conv.isId() && !conv.asId().isEmpty()) { + return conv.asId(); + } + } + if (responseContext != null) { + String sid = responseContext.getSessionId(); + if (sid != null && !sid.isEmpty()) { + return sid; + } + } + throw new IllegalStateException( + "Cannot resolve memory key for memory-aware supervisor invocation. " + + "Expected a non-empty value from previous_response_id, conversation.id, " + + "or ResponseContext.getSessionId(). To run without memory, set store=false " + + "or wire a plain SupervisorAgent."); + } + + // --- Input conversion from OpenAI format to Langchain4j messages --- + + private static Map convert(ResponseCreateParams.Body data) { + Map agentInput = new HashMap<>(); + List messages = new ArrayList<>(); + + // Add system message if instructions exist + if (data.instructions().isPresent() && data.instructions().isPresent()) { + messages.add(new SystemMessage(data.instructions().get())); + } + + if (data.input().isPresent()) { + ResponseCreateParams.Input input = data.input().get(); + if (input.isText() && + !input.asText().isEmpty()) { + messages.add(new UserMessage(input.asText())); + } else if (!input.asResponse().isEmpty()) { + for (ResponseInputItem inner : input.asResponse()) { + if (inner.isMessage()) { + messages.add(toLangChainMessage(inner.message().get())); + } else if (inner.isEasyInputMessage()) { + messages.add(toLangChainMessage(inner.easyInputMessage().get())); + } else { + throw new IllegalArgumentException("Unsupported input type: " + inner.getClass()); + } + } + } + } + + agentInput.put("messages", messages); + return agentInput; + } + + private static ChatMessage toLangChainMessage(EasyInputMessage easyInputMessage) { + List content = toLangChainContent(easyInputMessage.content()); + + // Workaround for probable bug in deserialization of EasyInputMessage role + EasyInputMessage.Role.Value role = EasyInputMessage.Role.Value.valueOf(easyInputMessage.role().asString().toUpperCase(Locale.ROOT)); + if (role == EasyInputMessage.Role.Value.USER) { + return new UserMessage(content); + } else if (role == EasyInputMessage.Role.Value.ASSISTANT) { + return new AiMessage(content.getFirst().toString()); + } else if (role == EasyInputMessage.Role.Value.DEVELOPER || role == EasyInputMessage.Role.Value.SYSTEM) { + return new SystemMessage(content.getFirst().toString()); // No developer message in langchain4j, using system message as fallback + } else { + throw new IllegalArgumentException("Unsupported role: " + role); + } + } + + private static List toLangChainContent(EasyInputMessage.Content content) { + List result = new ArrayList<>(); + if (content.isResponseInputMessageContentList()) { + return toLangChainContent(content.asResponseInputMessageContentList()); + } else if (content.isTextInput()) { + result.add(new TextContent(content.asTextInput())); + } + return result; + } + + private static ChatMessage toLangChainMessage(ResponseInputItem.Message message) { + if (message.type().get() == ResponseInputItem.Message.Type.MESSAGE) { + List content = toLangChainContent(message.content()); + + return switch (message.role().value()) { + case USER -> new UserMessage(content); + case SYSTEM, DEVELOPER -> + new SystemMessage(content.getFirst().toString()); // No developer message in langchain4j, using system message as fallback + default -> throw new IllegalStateException("Unexpected value: " + message.type().get()); + }; + } + throw new IllegalStateException("Unexpected value: " + message.type().get()); + } + + private static List toLangChainContent(List content) { + List result = new ArrayList<>(); + if (content != null) { + for (ResponseInputContent item : content) { + if (item.isInputText()) { + result.add(new TextContent(item.asInputText().text())); + } /*else if (item.isInputAudio()) { + // TODO: handle mime type + result.add(new AudioContent( + item.asInputAudio().inputAudio().data(), + "" // ? + )); + }*/ else if (item.isInputFile()) { + // TODO: handle mime type + result.add( + new PdfFileContent( + item.asInputFile().fileData().get(), + "" // ? + ) + ); + } else if (item.isInputImage()) { + result.add( + new ImageContent( + URI.create(item.asInputImage().imageUrl().get()) + ) + ); + } else { + throw new IllegalArgumentException("Unsupported content type: " + item.getClass()); + } + } + } + return result; + } + + /** + * Returns a builder that produces a ready-to-use {@link ResponsesApi} + * backed by a {@link AgentServerResponsesApi} wrapping this handler. + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private UntypedAgent agent; + private SupervisorAgentWithMemory supervisorAgent; + private ResponsesProvider provider; + + public Builder agent(UntypedAgent agent) { + this.agent = agent; + return this; + } + + public Builder supervisorAgent(SupervisorAgentWithMemory supervisorAgent) { + this.supervisorAgent = supervisorAgent; + return this; + } + + public Builder provider(ResponsesProvider provider) { + this.provider = provider; + return this; + } + + public ResponsesApi build() { + if (agent == null && supervisorAgent == null) { + throw new IllegalStateException("Either agent or supervisorAgent must be provided"); + } else if (agent != null && supervisorAgent != null) { + throw new IllegalStateException("Only one of agent or supervisorAgent can be provided"); + } + Langchain4jResponsesHandler handler = new Langchain4jResponsesHandler(agent, supervisorAgent); + ResponsesApi.Builder b = ResponsesApi.builder().responseHandler(handler); + if (provider != null) { + b.provider(provider); + } + return b.build(); + } + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/SupervisorAgentWithMemory.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/SupervisorAgentWithMemory.java new file mode 100644 index 000000000000..f53377e6ab32 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/SupervisorAgentWithMemory.java @@ -0,0 +1,10 @@ +package com.microsoft.agentserver.api.langchain4j; + +import dev.langchain4j.agentic.scope.ResultWithAgenticScope; +import dev.langchain4j.agentic.supervisor.SupervisorAgent; +import dev.langchain4j.service.MemoryId; +import dev.langchain4j.service.V; + +public interface SupervisorAgentWithMemory extends SupervisorAgent { + ResultWithAgenticScope invokeWithAgenticScope(@MemoryId String memoryId, @V("request") String var1); +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/noop/NOOPSupervisorAgent.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/noop/NOOPSupervisorAgent.java new file mode 100644 index 000000000000..501481fd1aa8 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/noop/NOOPSupervisorAgent.java @@ -0,0 +1,34 @@ +package com.microsoft.agentserver.api.langchain4j.noop; + +import dev.langchain4j.agentic.scope.AgenticScope; +import dev.langchain4j.agentic.scope.ResultWithAgenticScope; +import dev.langchain4j.agentic.supervisor.SupervisorAgent; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Alternative; + +@Alternative +@Priority(Integer.MIN_VALUE) +@ApplicationScoped +public class NOOPSupervisorAgent implements SupervisorAgent { + + @Override + public String invoke(String request) { + return ""; + } + + @Override + public ResultWithAgenticScope invokeWithAgenticScope(String request) { + return null; + } + + @Override + public AgenticScope getAgenticScope(Object memoryId) { + return null; + } + + @Override + public boolean evictAgenticScope(Object memoryId) { + return false; + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/noop/NOOPUntypedAgent.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/noop/NOOPUntypedAgent.java new file mode 100644 index 000000000000..f9a85a914073 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/java/com/microsoft/agentserver/api/langchain4j/noop/NOOPUntypedAgent.java @@ -0,0 +1,36 @@ +package com.microsoft.agentserver.api.langchain4j.noop; + +import dev.langchain4j.agentic.UntypedAgent; +import dev.langchain4j.agentic.scope.AgenticScope; +import dev.langchain4j.agentic.scope.ResultWithAgenticScope; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Alternative; + +import java.util.Map; + +@Alternative +@Priority(Integer.MIN_VALUE) +@ApplicationScoped +public class NOOPUntypedAgent implements UntypedAgent { + + @Override + public Object invoke(Map input) { + return null; + } + + @Override + public ResultWithAgenticScope invokeWithAgenticScope(Map input) { + return null; + } + + @Override + public AgenticScope getAgenticScope(Object memoryId) { + return null; + } + + @Override + public boolean evictAgenticScope(Object memoryId) { + return false; + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/resources/META-INF/beans.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/resources/META-INF/beans.xml new file mode 100644 index 000000000000..0eae218a41d2 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/main/resources/META-INF/beans.xml @@ -0,0 +1,7 @@ + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/test/java/com/microsoft/agentserver/api/langchain4j/OpenAIClientTest.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/test/java/com/microsoft/agentserver/api/langchain4j/OpenAIClientTest.java new file mode 100644 index 000000000000..d3713a4d106b --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j/src/test/java/com/microsoft/agentserver/api/langchain4j/OpenAIClientTest.java @@ -0,0 +1,63 @@ +package com.microsoft.agentserver.api.langchain4j; + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.responses.ResponseCreateParams; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Test class demonstrating how to use the openai-java library with the Responses API streaming. + *

+ * Requires the OPENAI_API_KEY environment variable to be set. + */ +@Disabled("For manual execution") +public class OpenAIClientTest { + + @Test + void testAskOpenAI_WhatIs3Plus4_Streaming() throws InterruptedException { + + // Build the OpenAI client + OpenAIClient client = OpenAIOkHttpClient.builder() + .apiKey("Ignore") + .baseUrl("http://localhost:8088/") + .build(); + + // Create the request using Responses API with streaming + ResponseCreateParams params = ResponseCreateParams.builder() + .model("gpt-4o") + .input("What is 3+4?") + .build(); + + StringBuilder responseText = new StringBuilder(); + AtomicBoolean receivedEvents = new AtomicBoolean(false); + + // Stream the response using try-with-resources + try (var streamingResponse = client.responses().createStreaming(params)) { + streamingResponse.stream() + .forEach(event -> { + receivedEvents.set(true); + + // Handle different event types + if (event.isOutputTextDelta()) { + String delta = event.asOutputTextDelta().delta(); + responseText.append(delta); + System.out.print(delta); + } else if (event.isCompleted()) { + System.out.println("\n\nStreaming completed."); + } + }); + } + + + Thread.sleep(10000); + + System.out.println("\nFull response: " + responseText); + + assertTrue(receivedEvents.get(), "Should have received streaming events"); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/pom.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/pom.xml new file mode 100644 index 000000000000..00653a976a2a --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/pom.xml @@ -0,0 +1,42 @@ + + 4.0.0 + + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../../../../pom.xml + + + azure-agentserver-api-jaxrs + jar + azure-agentserver-api-jaxrs + + JAX-RS adapter for the Azure AI Foundry Agent Server API + + + + com.microsoft.agentserver + azure-agentserver-api + + + jakarta.ws.rs + jakarta.ws.rs-api + compile + + + org.slf4j + slf4j-api + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-context + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/ApiExceptionMapper.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/ApiExceptionMapper.java new file mode 100644 index 000000000000..d294c84b0fb0 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/ApiExceptionMapper.java @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.jaxrs; + +import com.microsoft.agentserver.api.ApiError; +import com.microsoft.agentserver.api.ApiException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +import java.util.Map; + +/** + * JAX-RS {@link ExceptionMapper} that converts framework-agnostic {@link ApiException} + * instances into the standard error envelope: + * {@code { "error": { "message", "type", "code", "param"?, "details"?, "additionalInfo"? } }}. + * + */ +@Provider +public class ApiExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(ApiException exception) { + int statusCode = exception.getStatusCode(); + ApiError error = exception.getError() != null + ? exception.getError() + : ApiError.serverError("An internal error occurred."); + return Response.status(statusCode) + .type(MediaType.APPLICATION_JSON_TYPE) + .entity(Map.of("error", error)) + .build(); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/HealthResource.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/HealthResource.java new file mode 100644 index 000000000000..f1507500bf73 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/HealthResource.java @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.jaxrs; + +import com.microsoft.agentserver.api.HealthApi; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +/** + * JAX-RS resource classes for Kubernetes-style health probe endpoints. + *

+ * Delegates to a core {@link HealthApi} implementation to determine health status. + * Returns HTTP 200 OK when healthy, HTTP 503 Service Unavailable when not. + */ +public class HealthResource { + + private final HealthApi healthApi; + + /** + * Creates a health resource with custom health checks. + * + * @param healthApi the health API implementation + */ + public HealthResource(HealthApi healthApi) { + this.healthApi = healthApi; + } + + /** + * Creates a health resource with default (always-healthy) checks. + */ + public HealthResource() { + this(new HealthApi() { + }); + } + + /** + * Readiness probe endpoint. + */ + @Path("/readiness") + public static class Readiness { + private final HealthApi healthApi; + + public Readiness(HealthApi healthApi) { + this.healthApi = healthApi; + } + + public Readiness() { + this(new HealthApi() { + }); + } + + /** + * Handles GET requests to the readiness probe. + * + * @return HTTP 200 OK if ready, 503 if not + */ + @GET + public Response getReadiness() { + if (healthApi.isReady()) { + return Response.ok().build(); + } + return Response.status(Response.Status.SERVICE_UNAVAILABLE).build(); + } + } + + /** + * Liveness probe endpoint. + */ + @Path("/liveness") + public static class Liveness { + private final HealthApi healthApi; + + public Liveness(HealthApi healthApi) { + this.healthApi = healthApi; + } + + public Liveness() { + this(new HealthApi() { + }); + } + + /** + * Handles GET requests to the liveness probe. + * + * @return HTTP 200 OK if alive, 503 if not + */ + @GET + public Response getLiveness() { + if (healthApi.isAlive()) { + return Response.ok().build(); + } + return Response.status(Response.Status.SERVICE_UNAVAILABLE).build(); + } + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/InboundRequestLoggingFilter.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/InboundRequestLoggingFilter.java new file mode 100644 index 000000000000..f2d930a35410 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/InboundRequestLoggingFilter.java @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.jaxrs; + +import com.microsoft.agentserver.api.Observability; +import com.microsoft.agentserver.api.PlatformHeaders; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Scope; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import jakarta.ws.rs.ext.Provider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * JAX-RS filter that provides structured inbound request logging and OpenTelemetry + * span creation for all HTTP requests. + *

+ * For every request, this filter: + *

    + *
  • Records the start time for duration calculation
  • + *
  • Extracts trace context from headers (W3C traceparent) and starts a SERVER span
  • + *
  • Sets MDC fields ({@code requestId}, {@code traceId}, {@code spanId}) for structured logging
  • + *
  • On response: logs method, path, status code, duration, and request ID
  • + *
  • Sets HTTP semantic convention attributes on the span
  • + *
+ *

+ * This filter is always active (not gated by environment variables) because production + * observability requires consistent telemetry. It corresponds to the .NET + * {@code InboundRequestLoggingMiddleware} + trace instrumentation. + */ +@Provider +public class InboundRequestLoggingFilter implements ContainerRequestFilter, ContainerResponseFilter { + + private static final Logger LOGGER = LoggerFactory.getLogger(InboundRequestLoggingFilter.class); + + private static final String START_TIME_PROPERTY = "agentserver.startTimeNanos"; + private static final String SPAN_PROPERTY = "agentserver.span"; + private static final String SCOPE_PROPERTY = "agentserver.scope"; + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + // Record start time + requestContext.setProperty(START_TIME_PROPERTY, System.nanoTime()); + + // Extract headers for trace context propagation + Map headers = extractHeaders(requestContext); + + // Start a SERVER span with propagated context + String method = requestContext.getMethod(); + String path = requestContext.getUriInfo().getPath(); + String spanName = method + " /" + path; + + Span span = Observability.startServerSpan(spanName, headers); + Scope scope = span.makeCurrent(); + + requestContext.setProperty(SPAN_PROPERTY, span); + requestContext.setProperty(SCOPE_PROPERTY, scope); + + // Set MDC for structured logging correlation + String requestId = headers.getOrDefault(PlatformHeaders.REQUEST_ID, ""); + String traceId = span.getSpanContext().getTraceId(); + String spanId = span.getSpanContext().getSpanId(); + + MDC.put("requestId", requestId); + MDC.put("traceId", traceId); + MDC.put("spanId", spanId); + + // Set attributes on the span + span.setAttribute("http.request.method", method); + span.setAttribute("url.path", "/" + path); + + // Log client request ID if present (Azure SDK correlation) + String clientRequestId = headers.get(PlatformHeaders.CLIENT_REQUEST_ID); + if (clientRequestId != null && !clientRequestId.isEmpty()) { + span.setAttribute("az.client_request_id", clientRequestId); + } + } + + @Override + public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException { + // Calculate duration + Long startTime = (Long) requestContext.getProperty(START_TIME_PROPERTY); + long durationMs = 0; + if (startTime != null) { + durationMs = (System.nanoTime() - startTime) / 1_000_000; + } + + String method = requestContext.getMethod(); + String path = "/" + requestContext.getUriInfo().getPath(); + int statusCode = responseContext.getStatus(); + String requestId = MDC.get("requestId"); + + // Structured log — always emitted at INFO level + LOGGER.info("{} {} → {} ({}ms) [request_id={}]", + method, path, statusCode, durationMs, + requestId != null ? requestId : "-"); + + // End the span + Span span = (Span) requestContext.getProperty(SPAN_PROPERTY); + if (span != null) { + Observability.setHttpAttributes(span, method, path, statusCode); + span.setAttribute("http.response.duration_ms", durationMs); + span.end(); + } + + // Close the scope + Scope scope = (Scope) requestContext.getProperty(SCOPE_PROPERTY); + if (scope != null) { + scope.close(); + } + + // Clear MDC + MDC.remove("requestId"); + MDC.remove("traceId"); + MDC.remove("spanId"); + } + + private Map extractHeaders(ContainerRequestContext requestContext) { + Map headers = new LinkedHashMap<>(); + requestContext.getHeaders().forEach((key, values) -> { + if (values != null && !values.isEmpty()) { + headers.put(key.toLowerCase(), values.get(0)); + } + }); + return headers; + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/ObjectMapperProvider.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/ObjectMapperProvider.java new file mode 100644 index 000000000000..86df280e48ae --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/ObjectMapperProvider.java @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.jaxrs; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.agentserver.api.serialization.ObjectMapperFactory; +import jakarta.ws.rs.ext.ContextResolver; +import jakarta.ws.rs.ext.Provider; + +/** + * JAX-RS {@link ContextResolver} that provides the pre-configured {@link ObjectMapper} + * from {@link ObjectMapperFactory} to the JAX-RS runtime. + */ +@Provider +public class ObjectMapperProvider implements ContextResolver { + + @Override + public ObjectMapper getContext(Class type) { + return ObjectMapperFactory.getObjectMapper(); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/PlatformHeaderResponseFilter.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/PlatformHeaderResponseFilter.java new file mode 100644 index 000000000000..6965b238812d --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/PlatformHeaderResponseFilter.java @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.jaxrs; + +import com.microsoft.agentserver.api.PlatformHeaders; +import com.microsoft.agentserver.api.AgentServerVersion; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import jakarta.ws.rs.ext.Provider; +import org.slf4j.MDC; + +import java.util.UUID; + +/** + * JAX-RS response filter that implements platform header handling for all HTTP responses: + *

    + *
  • {@code x-request-id} — echoes the client-provided request ID or generates one.
  • + *
  • {@code x-platform-server} — identifies the server SDK stack.
  • + *
  • {@code x-agent-session-id} — echoed when the resource method publishes the + * resolved session ID under the request-context property of the same name.
  • + *
+ */ +@Provider +public class PlatformHeaderResponseFilter implements ContainerResponseFilter { + + private static final String MDC_REQUEST_ID = "requestId"; + + /** + * Request-context property the resource method publishes for the filter to echo. + */ + public static final String SESSION_ID_PROPERTY = "agent_session_id"; + + @Override + public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) { + // Resolve request ID: use client-provided header, or generate a new one + String requestId = requestContext.getHeaderString(PlatformHeaders.REQUEST_ID); + if (requestId == null || requestId.isBlank()) { + requestId = UUID.randomUUID().toString(); + } + + // Set platform headers on response + responseContext.getHeaders().putSingle(PlatformHeaders.REQUEST_ID, requestId); + responseContext.getHeaders().putSingle(PlatformHeaders.SERVER_VERSION, + AgentServerVersion.getInstance().getHeaderValue()); + + // echo the resolved agent_session_id when the resource method published it. + Object sessionIdAttr = requestContext.getProperty(SESSION_ID_PROPERTY); + if (sessionIdAttr instanceof String sid && !sid.isEmpty()) { + responseContext.getHeaders().putSingle(PlatformHeaders.SESSION_ID, sid); + } + + // Add to MDC for structured logging + MDC.put(MDC_REQUEST_ID, requestId); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/ResponsesResource.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/ResponsesResource.java new file mode 100644 index 000000000000..4978469391ea --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/ResponsesResource.java @@ -0,0 +1,348 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.jaxrs; + +import com.microsoft.agentserver.api.ApiError; +import com.microsoft.agentserver.api.ApiException; +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.AgentServerResponseItemList; +import com.microsoft.agentserver.api.CreateResponse; +import com.microsoft.agentserver.api.IsolationContext; +import com.microsoft.agentserver.api.PlatformHeaders; +import com.microsoft.agentserver.api.RequestMetadata; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseStreamReplay; +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.api.serialization.ObjectMapperFactory; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import jakarta.ws.rs.sse.OutboundSseEvent; +import jakarta.ws.rs.sse.Sse; +import jakarta.ws.rs.sse.SseEventSink; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * JAX-RS resource that exposes the {@link ResponsesApi} as HTTP endpoints. + *

+ * This class handles all JAX-RS-specific concerns (annotations, SSE transport, + * Response wrapping) and delegates business logic to the core {@link ResponsesApi}. + */ +@Path("/responses") +public class ResponsesResource { + + private static final Logger LOGGER = LoggerFactory.getLogger(ResponsesResource.class); + + private final ResponsesApi responsesApi; + + public ResponsesResource(ResponsesApi responsesApi) { + this.responsesApi = responsesApi; + } + + /** + * Create a model response (non-streaming). + */ + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response createResponse( + AgentServerCreateResponse createResponse, + @Context HttpHeaders headers, + @Context UriInfo uriInfo, + @Context ContainerRequestContext rc) throws ApiException { + RequestMetadata metadata = extractMetadata(headers, uriInfo); + CreateResponse result = responsesApi.createResponse(createResponse, metadata); + publishSessionId(rc, result.response()); + return Response.ok().entity(result).build(); + } + + /** + * Create a streaming model response (SSE). + */ + @POST + @Produces(MediaType.SERVER_SENT_EVENTS) + @Path("/streaming") + public void createStreamingResponse( + @Context SseEventSink eventSink, + @Context Sse sse, + @Context HttpHeaders headers, + @Context UriInfo uriInfo, + @Context ContainerRequestContext rc, + AgentServerCreateResponse createResponse) throws ApiException { + + if (eventSink == null) { + throw new ApiException(500, ApiError.serverError("SseEventSink was not injected properly")); + } + if (sse == null) { + throw new ApiException(500, ApiError.serverError("Sse context was not injected properly")); + } + + RequestMetadata metadata = extractMetadata(headers, uriInfo); + ResponseEventStream stream = responsesApi.createStreamingResponse(createResponse, metadata); + // Snapshot the response ID so we can signal client disconnect. + final String responseId = stream.getResponse() != null ? stream.getResponse().id() : null; + // publish the resolved session ID so the header filter echoes it. + publishSessionId(rc, stream.getResponse()); + + OutboundSseEvent.Builder eventBuilder = sse.newEventBuilder(); + + stream.subscribe( + event -> { + try { + String json = ObjectMapperFactory.getObjectMapper() + .writeValueAsString(event.streamEvent()); + LOGGER.debug("SSE event [{}]: {}", event.eventName(), json); + // Per the protocol: each SSE event is written as + // `event: {type}\ndata: {json}\n\n`. No `id:` line is emitted — the + // `sequence_number` field in the JSON payload is the resumption cursor. + eventSink.send(eventBuilder + .name(event.eventName()) + .data(json) + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .build()) + .toCompletableFuture() + .join(); + // detect client disconnect mid-stream. + if (eventSink.isClosed() && responseId != null) { + responsesApi.signalClientDisconnected(responseId); + } + } catch (Exception e) { + LOGGER.error("Failed to send SSE event (client may have disconnected)", e); + // treat send failure as client disconnect. + if (responseId != null) { + responsesApi.signalClientDisconnected(responseId); + } + } + }, + failure -> { + // the terminal event (already emitted by the library) + // signals stream completion. There is no `[DONE]` sentinel. + LOGGER.error("Stream failed", failure); + closeQuietly(eventSink); + }, + () -> { + // no `[DONE]` sentinel — the terminal event ends the stream. + closeQuietly(eventSink); + } + ); + } + + /** + * Get a model response by ID (non-streaming JSON). + *

+ * SSE replay ({@code ?stream=true}) is routed to {@link #getResponseStream} + * by {@link RoutingFilter}, mirroring the POST create/streaming split. + */ + @GET + @Path("/{response_id}") + @Produces(MediaType.APPLICATION_JSON) + public Response getResponse( + @PathParam("response_id") String responseId, + @QueryParam("include") List include, + @QueryParam("include_obfuscation") Boolean includeObfuscation, + @Context HttpHeaders headers, + @Context UriInfo uriInfo, + @Context ContainerRequestContext rc) throws ApiException { + RequestMetadata metadata = extractMetadata(headers, uriInfo); + com.openai.models.responses.Response resp = responsesApi.getResponse(responseId, include, metadata); + publishSessionId(rc, resp); + return Response.ok().entity(resp).build(); + } + + /** + * Replay a response's SSE event stream (the API spec, + * ). Reached via {@code GET /responses/{id}?stream=true}, which + * {@link RoutingFilter} rewrites to this {@code /stream} sub-resource. + *

+ * Uses the same {@link Sse}/{@link SseEventSink} machinery as + * {@link #createStreamingResponse} so both endpoints share identical SSE + * framing semantics (no {@code id:} line, no {@code [DONE]} + * sentinel). + */ + @GET + @Path("/{response_id}/stream") + @Produces(MediaType.SERVER_SENT_EVENTS) + public void getResponseStream( + @PathParam("response_id") String responseId, + @QueryParam("starting_after") Integer startingAfter, + @Context SseEventSink eventSink, + @Context Sse sse) throws ApiException { + // Precondition validation happens BEFORE touching the sink so + // ApiException (400/404) flows through the standard ApiExceptionMapper. + ResponseStreamReplay replay = responsesApi.replayResponseStream(responseId, startingAfter); + + if (eventSink == null) { + throw new ApiException(500, ApiError.serverError("SseEventSink was not injected properly")); + } + if (sse == null) { + throw new ApiException(500, ApiError.serverError("Sse context was not injected properly")); + } + + OutboundSseEvent.Builder eventBuilder = sse.newEventBuilder(); + try { + for (ResponseStreamReplay.ReplayEvent event : replay.events()) { + // `event: {type}\ndata: {json}\n\n`, no `id:` line — + // the `sequence_number` is already present in the JSON payload. + eventSink.send(eventBuilder + .name(event.eventName()) + .data(event.data()) + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .build()) + .toCompletableFuture() + .join(); + } + } finally { + // no `[DONE]` sentinel — closing the sink ends the stream. + closeQuietly(eventSink); + } + } + + /** + * Cancel a background model response by ID. + *

+ * Per the API spec, this cancels a + * {@code background=true} response that is still {@code queued} or + * {@code in_progress}, returning HTTP 200 with the cancelled {@link Response}. + * Rejections (synchronous response, terminal state, not found) surface as + * {@link ApiException} (400/404). + */ + @POST + @Path("/{response_id}/cancel") + @Produces(MediaType.APPLICATION_JSON) + public Response cancelResponse( + @PathParam("response_id") String responseId, + @Context ContainerRequestContext rc) throws ApiException { + com.openai.models.responses.Response resp = responsesApi.cancelResponse(responseId); + publishSessionId(rc, resp); + return Response.ok().entity(resp).build(); + } + + /** + * Delete a model response by ID. + *

+ * Per the API spec, a successful delete + * returns HTTP 200 with a body of the shape + * {@code { "id": "...", "object": "response", "deleted": true }}. + */ + @DELETE + @Path("/{response_id}") + @Produces(MediaType.APPLICATION_JSON) + public Response deleteResponse(@PathParam("response_id") String responseId) throws ApiException { + responsesApi.deleteResponse(responseId); + Map body = new LinkedHashMap<>(); + body.put("id", responseId); + body.put("object", "response"); + body.put("deleted", true); + return Response.ok().entity(body).build(); + } + + /** + * List input items for a response. + */ + @GET + @Path("/{response_id}/input_items") + @Produces(MediaType.APPLICATION_JSON) + public AgentServerResponseItemList listInputItems( + @PathParam("response_id") String responseId, + @QueryParam("limit") @DefaultValue("20") Integer limit, + @QueryParam("order") String order, + @QueryParam("after") String after, + @QueryParam("before") String before, + @QueryParam("include") List include) throws ApiException { + return responsesApi.listInputItems(responseId, limit, order, after, before, include); + } + + private static void closeQuietly(SseEventSink sink) { + try { + sink.close(); + } catch (Exception e) { + LOGGER.error("Failed to close SseEventSink", e); + } + } + + /** + * Publishes the resolved {@code agent_session_id} onto the JAX-RS + * request-context property that {@link PlatformHeaderResponseFilter} echoes + * as the {@code x-agent-session-id} response header. No-op when the response + * has no stamped session ID (e.g. legacy stored entries). + */ + private static void publishSessionId(ContainerRequestContext rc, com.openai.models.responses.Response resp) { + if (rc == null || resp == null) { + return; + } + com.openai.core.JsonValue val = resp._additionalProperties().get("agent_session_id"); + if (val == null) { + return; + } + @SuppressWarnings("unchecked") + java.util.Optional opt = val.asString(); + opt.filter(s -> !s.isEmpty()).ifPresent(s -> + rc.setProperty(PlatformHeaderResponseFilter.SESSION_ID_PROPERTY, s)); + } + + /** + * Extracts platform metadata from JAX-RS HttpHeaders and UriInfo. + */ + private RequestMetadata extractMetadata(HttpHeaders headers, UriInfo uriInfo) { + // Flatten headers into a single-value map (lowercase keys) + Map allHeaders = new LinkedHashMap<>(); + MultivaluedMap requestHeaders = headers.getRequestHeaders(); + if (requestHeaders != null) { + requestHeaders.forEach((key, values) -> { + if (values != null && !values.isEmpty()) { + allHeaders.put(key.toLowerCase(), values.get(0)); + } + }); + } + + // Isolation context + IsolationContext isolation = IsolationContext.fromHeaders(allHeaders); + + // Client headers (x-client-* prefix) + Map clientHeaders = IsolationContext.extractClientHeaders(allHeaders); + + // Query parameters + Map queryParameters; + MultivaluedMap queryParams = uriInfo != null ? uriInfo.getQueryParameters() : null; + if (queryParams == null || queryParams.isEmpty()) { + queryParameters = Collections.emptyMap(); + } else { + Map params = new LinkedHashMap<>(); + queryParams.forEach((key, values) -> { + if (values != null && !values.isEmpty()) { + params.put(key, values.get(0)); + } + }); + queryParameters = Collections.unmodifiableMap(params); + } + + // Request ID from header + String requestId = allHeaders.get(PlatformHeaders.REQUEST_ID); + + // x-agent-response-id request header overrides the generated response ID. + String responseIdOverride = allHeaders.get(PlatformHeaders.RESPONSE_ID_OVERRIDE); + + return new RequestMetadata(isolation, clientHeaders, queryParameters, requestId, responseIdOverride); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/RoutingFilter.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/RoutingFilter.java new file mode 100644 index 000000000000..d786b9518d00 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/RoutingFilter.java @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.jaxrs; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.PreMatching; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +/** + * JAX-RS pre-matching filter that reroutes requests to dedicated streaming + * sub-resources, mirroring the protocol's stream/non-stream split: + *

    + *
  • {@code POST /responses} with {@code "stream": true} in the body → + * {@code POST /responses/streaming}.
  • + *
  • {@code GET /responses/{id}?stream=true} (SSE replay) → + * {@code GET /responses/{id}/stream}.
  • + *
+ */ +@Provider +@PreMatching +public class RoutingFilter implements ContainerRequestFilter { + + private static final Logger LOGGER = LoggerFactory.getLogger(RoutingFilter.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * Maximum request body size (in bytes) the routing filter will buffer to inspect + * the {@code "stream"} flag. Requests exceeding this limit are rejected with + * HTTP 413 to prevent out-of-memory conditions. + */ + private static final int MAX_BODY_SIZE = 1024 * 1024; // 1 MB + + @Override + public void filter(ContainerRequestContext requestContext) { + String method = requestContext.getMethod(); + var uriInfo = requestContext.getUriInfo(); + String path = uriInfo.getPath(); + + // GET /responses/{id}?stream=true → SSE replay sub-resource. + if ("GET".equals(method)) { + if (path.matches("responses/[^/]+") + && "true".equalsIgnoreCase(uriInfo.getQueryParameters().getFirst("stream"))) { + var newUri = uriInfo.getRequestUriBuilder() + .replacePath(uriInfo.getBaseUri().getPath() + path + "/stream") + .build(); + LOGGER.debug("Routing filter: GET stream replay → {}", newUri); + requestContext.setRequestUri(newUri); + } + return; + } + + // POST /responses with "stream": true → streaming create. + if (!"responses".equals(path)) { + return; + } + + byte[] bodyBytes; + try { + bodyBytes = requestContext.getEntityStream().readNBytes(MAX_BODY_SIZE + 1); + } catch (IOException e) { + LOGGER.warn("Failed to read request body for stream routing detection"); + abortBadRequest(requestContext, "Unable to read request body"); + return; + } + + if (bodyBytes.length > MAX_BODY_SIZE) { + LOGGER.warn("Request body exceeds maximum size of {} bytes", MAX_BODY_SIZE); + requestContext.abortWith( + Response.status(413) + .entity("Request body too large") + .build()); + return; + } + + // Re-set the entity stream so downstream handlers can read it + requestContext.setEntityStream(new ByteArrayInputStream(bodyBytes)); + + try { + JsonNode jsonNode = MAPPER.readTree(bodyBytes); + boolean stream = jsonNode.has("stream") && jsonNode.get("stream").asBoolean(false); + + LOGGER.debug("Routing filter: stream={}, contentLength={}", stream, bodyBytes.length); + + if (stream) { + requestContext.setRequestUri(requestContext.getUriInfo().getBaseUriBuilder() + .path("responses/streaming") + .build()); + } + } catch (IOException e) { + LOGGER.warn("Failed to parse request body as JSON for stream routing detection"); + abortBadRequest(requestContext, "Invalid JSON in request body"); + } + } + + private static void abortBadRequest(ContainerRequestContext requestContext, String message) { + requestContext.abortWith( + Response.status(Response.Status.BAD_REQUEST) + .entity(message) + .build()); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/SseResponseFilter.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/SseResponseFilter.java new file mode 100644 index 000000000000..ca564f75123d --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/java/com/microsoft/agentserver/api/jaxrs/SseResponseFilter.java @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.api.jaxrs; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.ext.Provider; + +/** + * JAX-RS response filter that adds anti-buffering headers to SSE responses. + *

+ * Reverse proxies (nginx, Azure Front Door, Foundry proxy, etc.) buffer + * response bodies by default. For Server-Sent Events to stream correctly + * through these proxies, the following headers must be present: + *

    + *
  • {@code X-Accel-Buffering: no} — disables nginx proxy buffering
  • + *
  • {@code Cache-Control: no-cache} — prevents intermediate caching
  • + *
+ * Without these headers, the proxy accumulates the entire SSE stream before + * forwarding it to the client, causing the client to see an infinite spinner. + */ +@Provider +public class SseResponseFilter implements ContainerResponseFilter { + + @Override + public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) { + MediaType mediaType = responseContext.getMediaType(); + if (mediaType != null && MediaType.SERVER_SENT_EVENTS_TYPE.isCompatible(mediaType)) { + // Per the SSE Response Headers contract, declare an explicit charset. + responseContext.getHeaders().putSingle("Content-Type", "text/event-stream; charset=utf-8"); + responseContext.getHeaders().putSingle("X-Accel-Buffering", "no"); + responseContext.getHeaders().putSingle("Cache-Control", "no-cache"); + responseContext.getHeaders().putSingle("Connection", "keep-alive"); + } + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/resources/META-INF/beans.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/resources/META-INF/beans.xml new file mode 100644 index 000000000000..0eae218a41d2 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs/src/main/resources/META-INF/beans.xml @@ -0,0 +1,7 @@ + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/pom.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/pom.xml new file mode 100644 index 000000000000..0ef1d049330a --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../../../../pom.xml + + azure-agentserver-jersey + azure-agentserver-jersey + Jersey (JAX-RS) runtime adapter for the Azure AI Foundry Agent Server Server + + + + org.slf4j + slf4j-api + + + + org.glassfish.jersey.containers + jersey-container-grizzly2-http + compile + + + org.glassfish.jersey.inject + jersey-hk2 + compile + + + org.glassfish.jersey.media + jersey-media-json-jackson + compile + + + org.glassfish.jersey.media + jersey-media-sse + compile + + + com.microsoft.agentserver + azure-agentserver-api-jaxrs + + + com.microsoft.agentserver + azure-agentserver-api + + + jakarta.enterprise + jakarta.enterprise.cdi-api + + + + + org.junit.jupiter + junit-jupiter + test + + + org.slf4j + slf4j-simple + test + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/java/com/microsoft/agentserver/server/jersey/ExceptionMappers.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/java/com/microsoft/agentserver/server/jersey/ExceptionMappers.java new file mode 100644 index 000000000000..75975b9823de --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/java/com/microsoft/agentserver/server/jersey/ExceptionMappers.java @@ -0,0 +1,55 @@ +package com.microsoft.agentserver.server.jersey; + +import com.microsoft.agentserver.api.ApiError; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +@Provider +public class ExceptionMappers { + + @Provider + public static class NotFoundExceptionMapper implements ExceptionMapper { + private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(NotFoundExceptionMapper.class); + + @Context + private UriInfo uriInfo; + + @Override + public Response toResponse(NotFoundException exception) { + String failedUri = (uriInfo != null) ? uriInfo.getRequestUri().toString() : "unknown"; + LOGGER.warn("404 Not Found: {} (Request URI: {})", exception.getMessage(), failedUri); + + return Response.status(Response.Status.NOT_FOUND) + .type(MediaType.APPLICATION_JSON_TYPE) + .entity(Map.of("error", ApiError.invalidRequest("Resource not found"))) + .build(); + } + } + + @Provider + public static class GenericExceptionMapper implements ExceptionMapper { + private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(GenericExceptionMapper.class); + + @Context + private UriInfo uriInfo; + + @Override + public Response toResponse(Exception exception) { + String failedUri = (uriInfo != null) ? uriInfo.getRequestUri().toString() : "unknown"; + LOGGER.error("Unhandled exception at {}: {}", failedUri, exception.getMessage(), exception); + + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .type(MediaType.APPLICATION_JSON_TYPE) + .entity(Map.of("error", ApiError.serverError("An internal error occurred."))) + .build(); + } + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/java/com/microsoft/agentserver/server/jersey/Filters.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/java/com/microsoft/agentserver/server/jersey/Filters.java new file mode 100644 index 000000000000..4412f4b1ae58 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/java/com/microsoft/agentserver/server/jersey/Filters.java @@ -0,0 +1,58 @@ +package com.microsoft.agentserver.server.jersey; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import jakarta.ws.rs.ext.Provider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +public class Filters { + + private static final Boolean LOG_REQUESTS = Boolean.parseBoolean(System.getenv().getOrDefault("CA_LOG_REQUESTS", "false")); + + private static final Logger LOGGER = LoggerFactory.getLogger(Filters.class); + + @Provider + public static class RequestLoggingFilter implements ContainerRequestFilter { + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + if (!LOG_REQUESTS) { + return; + } + LOGGER.info("Incoming request: {} {} from {}", + requestContext.getMethod(), + requestContext.getUriInfo().getRequestUri(), + requestContext.getHeaders().getFirst("X-Forwarded-For")); + } + } + + @Provider + public static class ResponseLoggingFilter implements ContainerResponseFilter { + @Override + public void filter(ContainerRequestContext requestContext, jakarta.ws.rs.container.ContainerResponseContext responseContext) throws IOException { + if (!LOG_REQUESTS) { + return; + } + LOGGER.info("Outgoing response: {} for {} {}", + responseContext.getStatus(), + requestContext.getMethod(), + requestContext.getUriInfo().getRequestUri()); + } + } + + @Provider + public static class CorsFilter implements ContainerResponseFilter { + + @Override + public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException { + responseContext.getHeaders().add("Access-Control-Allow-Origin", "*"); + responseContext.getHeaders().add("Access-Control-Allow-Credentials", "true"); + responseContext.getHeaders().add("Access-Control-Allow-Headers", "origin, content-type, accept, authorization"); + responseContext.getHeaders().add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, HEAD"); + } + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/java/com/microsoft/agentserver/server/jersey/JerseyAgentServerAdaptorService.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/java/com/microsoft/agentserver/server/jersey/JerseyAgentServerAdaptorService.java new file mode 100644 index 000000000000..1b4497de56c2 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/java/com/microsoft/agentserver/server/jersey/JerseyAgentServerAdaptorService.java @@ -0,0 +1,94 @@ +package com.microsoft.agentserver.server.jersey; + +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.api.jaxrs.ApiExceptionMapper; +import com.microsoft.agentserver.api.jaxrs.HealthResource; +import com.microsoft.agentserver.api.jaxrs.InboundRequestLoggingFilter; +import com.microsoft.agentserver.api.jaxrs.ObjectMapperProvider; +import com.microsoft.agentserver.api.jaxrs.PlatformHeaderResponseFilter; +import com.microsoft.agentserver.api.jaxrs.ResponsesResource; +import com.microsoft.agentserver.api.jaxrs.RoutingFilter; +import com.microsoft.agentserver.api.jaxrs.SseResponseFilter; +import jakarta.enterprise.context.ApplicationScoped; +import org.glassfish.grizzly.http.server.HttpServer; +import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory; +import org.glassfish.jersey.inject.hk2.AbstractBinder; +import org.glassfish.jersey.jackson.JacksonFeature; +import org.glassfish.jersey.media.sse.SseFeature; +import org.glassfish.jersey.media.sse.internal.SseBinder; +import org.glassfish.jersey.media.sse.internal.SseEventSinkValueParamProvider; +import org.glassfish.jersey.server.ResourceConfig; + +import java.net.URI; + +public class JerseyAgentServerAdaptorService { + + private static final Boolean LOG_REQUESTS = Boolean.parseBoolean(System.getenv().getOrDefault("CA_LOG_REQUESTS", "false")); + + public static class AgentBinder extends AbstractBinder { + private final ResponsesApi responsesApi; + + public AgentBinder(ResponsesApi responsesApi) { + this.responsesApi = responsesApi; + } + + @Override + protected void configure() { + bind(new ResponsesResource(responsesApi)).to(ResponsesResource.class).in(ApplicationScoped.class); + } + } + + /** + * Builds and returns an HttpServer hosting the given UntypedAgent at the default http://0.0.0.0:8088. + * + * @param res The ResponsesApi instance to be served. + * @return An instance of HttpServer hosting the agent service. + */ + public static HttpServer buildAgent(ResponsesApi res) { + return getHttpServer("http://0.0.0.0:8088", new AgentBinder(res)); + } + + /** + * Builds and returns an HttpServer hosting the given UntypedAgent at the specified base URI. + * + * @param baseUri The base URI where the agent service will be hosted i.e "http://0.0.0.0:8088". + * @param res The ResponsesApi instance to be served. + * @return An instance of HttpServer hosting the agent service. + */ + public static HttpServer buildAgent(String baseUri, ResponsesApi res) { + return getHttpServer(baseUri, new AgentBinder(res)); + } + + private static HttpServer getHttpServer(String baseUri, AgentBinder agentBinder) { + ResourceConfig rc = new ResourceConfig() + .register(agentBinder) + .register(ResponsesResource.class) + .register(new SseBinder()) + .register(SseEventSinkValueParamProvider.class) + .register(HealthResource.Liveness.class) + .register(HealthResource.Readiness.class) + .register(JacksonFeature.class) + .register(ObjectMapperProvider.class) + .register(Filters.CorsFilter.class) + .register(SseFeature.class) + .register(RoutingFilter.class) + .register(SseResponseFilter.class) + .register(PlatformHeaderResponseFilter.class) + .register(InboundRequestLoggingFilter.class) + .register(ApiExceptionMapper.class) + .register(ExceptionMappers.NotFoundExceptionMapper.class) + .register(ExceptionMappers.GenericExceptionMapper.class); + + if (LOG_REQUESTS) { + rc = rc + .register(Filters.RequestLoggingFilter.class) + .register(Filters.ResponseLoggingFilter.class); + } + + return build(URI.create(baseUri), rc); + } + + public static HttpServer build(URI uri, ResourceConfig rc) { + return GrizzlyHttpServerFactory.createHttpServer(uri, rc); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/resources/META-INF/beans.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/resources/META-INF/beans.xml new file mode 100644 index 000000000000..0eae218a41d2 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/main/resources/META-INF/beans.xml @@ -0,0 +1,7 @@ + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/test/java/com/microsoft/agentserver/server/jersey/JerseyServerIntegrationTest.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/test/java/com/microsoft/agentserver/server/jersey/JerseyServerIntegrationTest.java new file mode 100644 index 000000000000..f0c044fa1e66 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey/src/test/java/com/microsoft/agentserver/server/jersey/JerseyServerIntegrationTest.java @@ -0,0 +1,747 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.server.jersey; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.CreateResponse; +import com.microsoft.agentserver.api.ResponseBuilder; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.api.ResponsesProvider; +import com.microsoft.agentserver.api.serialization.ObjectMapperFactory; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseOutputText; +import org.glassfish.grizzly.http.server.HttpServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * End-to-end integration tests for the Jersey HTTP server adapter. + * Boots a real Grizzly HTTP server with the full Jersey stack and makes real HTTP requests + * using {@link HttpURLConnection} (which properly handles Content-Length for POST bodies). + *

+ * SSE streaming tests are intentionally excluded — the streaming pipeline is fully + * covered by the core API module tests. + */ +@Timeout(30) +class JerseyServerIntegrationTest { + + private static final String BASE_URI = "http://localhost:19876"; + private static final ObjectMapper MAPPER = ObjectMapperFactory.getObjectMapper(); + /** + * A well-formed but non-existent response ID (caresp_ + 50 chars) for 404 tests. + */ + private static final String MISSING_ID = "caresp_" + "A".repeat(50); + private static HttpServer server; + + /** + * Simple response wrapper for test assertions. + */ + record TestResponse(int statusCode, String body, Map> headers) { + } + + @BeforeAll + static void startServer() { + ResponseHandler echoHandler = new ResponseHandler() { + @Override + public CreateResponse createResponse(ResponseContext ctx, AgentServerCreateResponse request) { + String inputText = request.inputText(); + if (inputText.isEmpty()) inputText = "(no input)"; + ResponseOutputText outputText = ResponseOutputText.builder() + .text("Echo: " + inputText) + .annotations(List.of()) + .build(); + Response resp = ResponseBuilder.convertOutputToResponse(request, outputText); + return new CreateResponse(null, resp); + } + + @Override + public ResponseEventStream createAsync(ResponseContext ctx, AgentServerCreateResponse request) { + ResponseEventStream stream = ResponseEventStream.create(ctx, request); + stream.emitCreated() + .emitInProgress() + .addOutputMessage(msg -> msg + .outputItemMessage("Streaming: " + request.inputText())) + .emitCompleted(); + return stream; + } + }; + + ResponsesApi api = ResponsesApi.builder() + .responseHandler(echoHandler) + .provider(ResponsesProvider.inMemory()) + .build(); + + server = JerseyAgentServerAdaptorService.buildAgent(BASE_URI, api); + } + + @AfterAll + static void stopServer() { + if (server != null) { + server.shutdownNow(); + } + } + + // ── HTTP helpers using HttpURLConnection ───────────────────── + + private TestResponse post(String path, String jsonBody) throws Exception { + return post(path, jsonBody, Map.of()); + } + + private TestResponse post(String path, String jsonBody, Map extraHeaders) throws Exception { + HttpURLConnection conn = (HttpURLConnection) URI.create(BASE_URI + path).toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + extraHeaders.forEach(conn::setRequestProperty); + conn.setConnectTimeout(5000); + conn.setReadTimeout(10000); + conn.setDoOutput(true); + byte[] bodyBytes = jsonBody.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(bodyBytes.length); + try (var out = conn.getOutputStream()) { + out.write(bodyBytes); + out.flush(); + } + return readResponse(conn); + } + + private TestResponse get(String path) throws Exception { + HttpURLConnection conn = (HttpURLConnection) URI.create(BASE_URI + path).toURL().openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(10000); + return readResponse(conn); + } + + private TestResponse delete(String path) throws Exception { + HttpURLConnection conn = (HttpURLConnection) URI.create(BASE_URI + path).toURL().openConnection(); + conn.setRequestMethod("DELETE"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(10000); + return readResponse(conn); + } + + private TestResponse postNoBody(String path) throws Exception { + HttpURLConnection conn = (HttpURLConnection) URI.create(BASE_URI + path).toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(10000); + conn.setFixedLengthStreamingMode(0); + conn.setDoOutput(true); + try (var out = conn.getOutputStream()) { + out.flush(); + } + return readResponse(conn); + } + + private TestResponse readResponse(HttpURLConnection conn) throws Exception { + int status = conn.getResponseCode(); + String body; + try (InputStream in = (status >= 400 ? conn.getErrorStream() : conn.getInputStream())) { + body = in != null ? new String(in.readAllBytes(), StandardCharsets.UTF_8) : ""; + } + Map> headers = conn.getHeaderFields(); + conn.disconnect(); + return new TestResponse(status, body, headers); + } + + // ══════════════════════════════════════════════════════════════ + // Non-streaming response creation + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("POST /responses (non-streaming)") + class CreateResponseEndpoint { + + @Test + @DisplayName("Returns 200 with valid response JSON") + void createResponseReturns200() throws Exception { + TestResponse response = post("/responses", + "{\"input\": \"Hello, agent!\", \"model\": \"test-model\"}"); + + assertEquals(200, response.statusCode()); + JsonNode json = MAPPER.readTree(response.body()); + assertTrue(json.has("id")); + assertTrue(json.get("id").asText().startsWith("caresp_")); + assertTrue(json.has("output")); + assertTrue(json.has("status")); + } + + @Test + @DisplayName("Response output is a non-empty array") + void responseContainsOutput() throws Exception { + TestResponse response = post("/responses", + "{\"input\": \"Test message\", \"model\": \"gpt-4o\"}"); + assertEquals(200, response.statusCode()); + + JsonNode json = MAPPER.readTree(response.body()); + JsonNode output = json.get("output"); + assertNotNull(output); + assertTrue(output.isArray()); + assertFalse(output.isEmpty()); + } + + @Test + @DisplayName("Response includes model field") + void responseIncludesModel() throws Exception { + TestResponse response = post("/responses", + "{\"input\": \"Test\", \"model\": \"gpt-4o\"}"); + assertEquals(200, response.statusCode()); + + JsonNode json = MAPPER.readTree(response.body()); + assertTrue(json.has("model")); + } + + @Test + @DisplayName("x-agent-response-id header overrides the generated id") + void responseIdHeaderOverride() throws Exception { + String custom = "caresp_" + "Z".repeat(50); + TestResponse response = post("/responses", + "{\"input\": \"override me\", \"model\": \"test-model\"}", + Map.of("x-agent-response-id", custom)); + assertEquals(200, response.statusCode()); + + JsonNode json = MAPPER.readTree(response.body()); + assertEquals(custom, json.get("id").asText(), + "the returned response.id must match the x-agent-response-id header"); + + // The override survives round-trip through storage. + TestResponse getResp = get("/responses/" + custom); + assertEquals(200, getResp.statusCode()); + assertEquals(custom, MAPPER.readTree(getResp.body()).get("id").asText()); + } + + @Test + @DisplayName("x-agent-session-id response header is echoed; GET on the same id echoes too") + void sessionIdResponseHeaderEcho() throws Exception { + // Provide a client-supplied agent_session_id via the request body + // (Tier 1 of the resolver chain) so the value is deterministic. + TestResponse createResp = post("/responses", + "{\"input\": \"sid please\", \"model\": \"test-model\", " + + "\"agent_session_id\": \"client-session-42\"}"); + assertEquals(200, createResp.statusCode()); + + List created = createResp.headers().get("x-agent-session-id"); + assertNotNull(created, "x-agent-session-id header must be present on create"); + assertTrue(created.contains("client-session-42"), + "create response must echo the resolved session ID, got: " + created); + + // GET on the stored response must also echo the same header. + String id = MAPPER.readTree(createResp.body()).get("id").asText(); + TestResponse getResp = get("/responses/" + id); + assertEquals(200, getResp.statusCode()); + List echoed = getResp.headers().get("x-agent-session-id"); + assertNotNull(echoed, "GET response must echo x-agent-session-id"); + assertTrue(echoed.contains("client-session-42"), + "GET must echo the stored session ID, got: " + echoed); + } + } + + // ══════════════════════════════════════════════════════════════ + // GET / DELETE response endpoints + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("GET / DELETE /responses/{id}") + class GetDeleteEndpoints { + + @Test + @DisplayName("Create then GET retrieves the stored response") + void createThenGet() throws Exception { + TestResponse createResp = post("/responses", + "{\"input\": \"Store me\", \"model\": \"test-model\"}"); + assertEquals(200, createResp.statusCode()); + + String responseId = MAPPER.readTree(createResp.body()).get("id").asText(); + + TestResponse getResp = get("/responses/" + responseId); + assertEquals(200, getResp.statusCode()); + assertEquals(responseId, MAPPER.readTree(getResp.body()).get("id").asText()); + } + + @Test + @DisplayName("GET non-existent response returns 404") + void getNonExistentReturns404() throws Exception { + TestResponse response = get("/responses/" + MISSING_ID); + assertEquals(404, response.statusCode()); + } + + @Test + @DisplayName("DELETE response returns 200 with deleted body") + void deleteReturns200() throws Exception { + TestResponse createResp = post("/responses", + "{\"input\": \"Delete me\", \"model\": \"test-model\"}"); + String responseId = MAPPER.readTree(createResp.body()).get("id").asText(); + + TestResponse deleteResp = delete("/responses/" + responseId); + assertEquals(200, deleteResp.statusCode()); + JsonNode deleted = MAPPER.readTree(deleteResp.body()); + assertEquals(responseId, deleted.get("id").asText()); + assertEquals("response", deleted.get("object").asText()); + assertTrue(deleted.get("deleted").asBoolean()); + + TestResponse getResp = get("/responses/" + responseId); + assertEquals(404, getResp.statusCode()); + } + } + + // ══════════════════════════════════════════════════════════════ + // Input items endpoint + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("GET /responses/{id}/input_items") + class InputItemsEndpoint { + + @Test + @DisplayName("List input items for a stored response") + void listInputItems() throws Exception { + TestResponse createResp = post("/responses", + "{\"input\": \"Items test\", \"model\": \"test-model\"}"); + String responseId = MAPPER.readTree(createResp.body()).get("id").asText(); + + TestResponse itemsResp = get("/responses/" + responseId + "/input_items"); + assertEquals(200, itemsResp.statusCode()); + assertTrue(MAPPER.readTree(itemsResp.body()).has("data")); + } + + @Test + @DisplayName("List input items for non-existent response returns 404") + void listItemsNonExistentReturns404() throws Exception { + TestResponse response = get("/responses/" + MISSING_ID + "/input_items"); + assertEquals(404, response.statusCode()); + } + + @Test + @DisplayName("Malformed response ID returns 400") + void malformedIdReturns400() throws Exception { + // Validated before lookup on GET, DELETE, cancel, input_items, and SSE replay. + assertEquals(400, get("/responses/not-a-valid-id").statusCode()); + assertEquals(400, delete("/responses/caresp_tooshort").statusCode()); + assertEquals(400, postNoBody("/responses/bad-id/cancel").statusCode()); + assertEquals(400, get("/responses/resp_wrongprefix/input_items").statusCode()); + assertEquals(400, get("/responses/caresp_short?stream=true").statusCode()); + } + } + + // ══════════════════════════════════════════════════════════════ + // Cancel endpoint + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("POST /responses/{id}/cancel") + class CancelEndpoint { + + @Test + @DisplayName("Cancel non-existent response returns 404") + void cancelNonExistentReturns404() throws Exception { + assertEquals(404, postNoBody("/responses/" + MISSING_ID + "/cancel").statusCode()); + } + + @Test + @DisplayName("Cancel a synchronous (non-background) response returns 400") + void cancelSynchronousReturns400() throws Exception { + TestResponse createResp = post("/responses", + "{\"input\": \"sync\", \"model\": \"test-model\"}"); + String responseId = MAPPER.readTree(createResp.body()).get("id").asText(); + + TestResponse cancelResp = postNoBody("/responses/" + responseId + "/cancel"); + assertEquals(400, cancelResp.statusCode()); + assertTrue(cancelResp.body().contains("Cannot cancel a synchronous response."), + "Body should explain synchronous cancel rejection. Was: " + cancelResp.body()); + } + } + + // ══════════════════════════════════════════════════════════════ + // SSE replay via GET + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("GET /responses/{id}?stream=true (SSE replay)") + class ReplayEndpoint { + + private String createBackgroundStream() throws Exception { + TestResponse createResp = post("/responses", + "{\"input\": \"replay me\", \"model\": \"test-model\", \"background\": true, \"stream\": true}"); + assertEquals(200, createResp.statusCode()); + // The streaming SSE body carries the response object inside its events. + // Recover the id from a subsequent GET is unnecessary — parse from the created event. + JsonNode createdEvent = null; + for (String block : createResp.body().split("\n\n")) { + if (block.contains("response.created")) { + for (String line : block.split("\n")) { + if (line.startsWith("data:")) { + createdEvent = MAPPER.readTree(line.substring("data:".length()).trim()); + } + } + } + } + assertNotNull(createdEvent, "stream should contain a response.created event"); + return createdEvent.get("response").get("id").asText(); + } + + @Test + @DisplayName("Replays stored events for a background streaming response; honours starting_after") + void replaysBackgroundStream() throws Exception { + String id = createBackgroundStream(); + + // Full replay. + TestResponse full = get("/responses/" + id + "?stream=true"); + assertEquals(200, full.statusCode()); + String contentType = String.join(",", full.headers().getOrDefault("Content-Type", List.of())); + assertTrue(contentType.contains("text/event-stream"), "Content-Type was: " + contentType); + assertTrue(full.body().contains("event: response.created"), "full replay body:\n" + full.body()); + assertTrue(full.body().contains("event: response.completed")); + assertFalse(full.body().contains("[DONE]"), "replay must not contain [DONE]"); + + // starting_after=0 skips the response.created event (sequence_number 0). + TestResponse after0 = get("/responses/" + id + "?stream=true&starting_after=0"); + assertEquals(200, after0.statusCode()); + assertFalse(after0.body().contains("event: response.created"), + "starting_after=0 should skip seq 0. Body:\n" + after0.body()); + assertTrue(after0.body().contains("event: response.completed")); + } + + @Test + @DisplayName("C4: background+stream response is retrievable as background via JSON GET") + void c4ResponseIsRetrievableAsBackground() throws Exception { + String id = createBackgroundStream(); + + // After the SSE stream returned, the response must be retrievable via JSON GET, + // with background=true persisted (so cancel/replay invariants hold). + TestResponse getResp = get("/responses/" + id); + assertEquals(200, getResp.statusCode()); + JsonNode node = MAPPER.readTree(getResp.body()); + assertEquals(id, node.get("id").asText()); + assertTrue(node.has("background") && node.get("background").asBoolean(), + "C4 response must carry background=true. Body: " + getResp.body()); + // Status reached a terminal — completed for our echo handler. + assertEquals("completed", node.get("status").asText()); + } + + @Test + @DisplayName("Replay on a non-background streaming response → 400") + void replayNonBackgroundReturns400() throws Exception { + TestResponse createResp = post("/responses", + "{\"input\": \"sync stream\", \"model\": \"test-model\", \"stream\": true}"); + assertEquals(200, createResp.statusCode()); + // Recover the id from the created event. + String id = null; + for (String block : createResp.body().split("\n\n")) { + if (block.contains("response.created")) { + for (String line : block.split("\n")) { + if (line.startsWith("data:")) { + id = MAPPER.readTree(line.substring("data:".length()).trim()) + .get("response").get("id").asText(); + } + } + } + } + assertNotNull(id); + + TestResponse replay = get("/responses/" + id + "?stream=true"); + assertEquals(400, replay.statusCode()); + assertTrue(replay.body().contains("background=true"), "Body: " + replay.body()); + } + + @Test + @DisplayName("Replay on a background non-streaming response → 400") + void replayNonStreamReturns400() throws Exception { + TestResponse createResp = post("/responses", + "{\"input\": \"bg only\", \"model\": \"test-model\", \"background\": true}"); + assertEquals(200, createResp.statusCode()); + String id = MAPPER.readTree(createResp.body()).get("id").asText(); + + TestResponse replay = get("/responses/" + id + "?stream=true"); + assertEquals(400, replay.statusCode()); + assertTrue(replay.body().contains("stream=true"), "Body: " + replay.body()); + } + + @Test + @DisplayName("Replay on a non-existent response → 404") + void replayNotFoundReturns404() throws Exception { + assertEquals(404, get("/responses/" + MISSING_ID + "?stream=true").statusCode()); + } + } + + // ══════════════════════════════════════════════════════════════ + // Background mode + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("POST /responses (background)") + class BackgroundEndpoint { + + @Test + @DisplayName("background=true returns 200 with in_progress and becomes retrievable/completed") + void backgroundReturnsInProgress() throws Exception { + TestResponse createResp = post("/responses", + "{\"input\": \"bg\", \"model\": \"test-model\", \"background\": true}"); + assertEquals(200, createResp.statusCode()); + + JsonNode created = MAPPER.readTree(createResp.body()); + String id = created.get("id").asText(); + assertEquals("in_progress", created.get("status").asText()); + + // The background response is immediately retrievable and eventually completes. + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10); + String status = null; + while (System.nanoTime() < deadline) { + TestResponse getResp = get("/responses/" + id); + assertEquals(200, getResp.statusCode()); + status = MAPPER.readTree(getResp.body()).get("status").asText(); + if ("completed".equals(status)) { + break; + } + Thread.sleep(20); + } + assertEquals("completed", status, "background response should reach completed"); + } + + @Test + @DisplayName("background=true + store=false returns 400") + void backgroundStoreFalseReturns400() throws Exception { + TestResponse resp = post("/responses", + "{\"input\": \"bg\", \"model\": \"test-model\", \"background\": true, \"store\": false}"); + assertEquals(400, resp.statusCode()); + } + } + + // ══════════════════════════════════════════════════════════════ + // SSE streaming wire-format contract + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("POST /responses (streaming SSE)") + class StreamingEndpoint { + + @Test + @DisplayName("Streaming returns text/event-stream with terminal event and no [DONE] sentinel") + void streamingWireFormat() throws Exception { + TestResponse response = post("/responses", + "{\"input\": \"stream please\", \"model\": \"test-model\", \"stream\": true}"); + + assertEquals(200, response.statusCode()); + + // SSE Content-Type must declare charset=utf-8. + String contentType = String.join(",", response.headers() + .getOrDefault("Content-Type", List.of())); + assertTrue(contentType.contains("text/event-stream"), + "Content-Type should be text/event-stream, was: " + contentType); + + String body = response.body(); + // exactly one terminal event ends the stream; no [DONE] sentinel. + assertTrue(body.contains("event: response.completed") + || body.contains("event:response.completed"), + "Stream should contain a terminal response.completed event. Body:\n" + body); + assertFalse(body.contains("[DONE]"), + "Stream must NOT contain a [DONE] sentinel. Body:\n" + body); + + // each event is `event: {type}\ndata: {json}\n\n` — no `id:` line. + for (String line : body.split("\n")) { + assertFalse(line.startsWith("id:") || line.startsWith("id :"), + "SSE stream must NOT emit an `id:` line. Offending line: " + line); + } + } + } + + // ══════════════════════════════════════════════════════════════ + // Health endpoints + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Health probe endpoints") + class HealthEndpoints { + + @Test + @DisplayName("GET /readiness returns 200") + void readinessReturns200() throws Exception { + assertEquals(200, get("/readiness").statusCode()); + } + + @Test + @DisplayName("GET /liveness returns 200") + void livenessReturns200() throws Exception { + assertEquals(200, get("/liveness").statusCode()); + } + } + + // ══════════════════════════════════════════════════════════════ + // CORS headers + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("CORS filter") + class CorsHeaders { + + @Test + @DisplayName("Responses include CORS Allow-Origin header") + void corsHeadersPresent() throws Exception { + TestResponse response = get("/readiness"); + assertEquals(200, response.statusCode()); + List origin = response.headers().get("Access-Control-Allow-Origin"); + assertNotNull(origin, "Should have Access-Control-Allow-Origin header"); + assertTrue(origin.contains("*")); + } + + @Test + @DisplayName("CORS allows standard methods") + void corsAllowsMethods() throws Exception { + TestResponse response = get("/readiness"); + List methods = response.headers().get("Access-Control-Allow-Methods"); + assertNotNull(methods); + String methodsStr = String.join(",", methods); + assertTrue(methodsStr.contains("GET")); + assertTrue(methodsStr.contains("POST")); + assertTrue(methodsStr.contains("DELETE")); + } + } + + // ══════════════════════════════════════════════════════════════ + // Exception handling + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Exception handling") + class ExceptionHandling { + + @Test + @DisplayName("404 for unknown path") + void unknownPathReturns404() throws Exception { + assertEquals(404, get("/nonexistent/path").statusCode()); + } + + // Error envelope contract: + // every HTTP error returns `{ "error": { "message", "type", "code", "param"?,... } }`. + + @Test + @DisplayName("404 envelope: type=invalid_request_error, code=invalid_request_error, message includes ID") + void notFoundUsesStructuredEnvelope() throws Exception { + TestResponse resp = get("/responses/" + MISSING_ID); + assertEquals(404, resp.statusCode()); + JsonNode error = MAPPER.readTree(resp.body()).get("error"); + assertNotNull(error, "body must wrap an `error` object"); + assertEquals("invalid_request_error", error.get("type").asText()); + assertEquals("invalid_request_error", error.get("code").asText()); + assertTrue(error.get("message").asText().contains(MISSING_ID), + "message should include the response ID, was: " + error.get("message").asText()); + } + + @Test + @DisplayName("Malformed path ID envelope: code=invalid_parameters, param=responseId{}") + void malformedIdUsesStructuredEnvelope() throws Exception { + TestResponse resp = get("/responses/not-a-valid-id"); + assertEquals(400, resp.statusCode()); + JsonNode error = MAPPER.readTree(resp.body()).get("error"); + assertNotNull(error); + assertEquals("invalid_request_error", error.get("type").asText()); + assertEquals("invalid_parameters", error.get("code").asText()); + assertEquals("responseId{not-a-valid-id}", error.get("param").asText()); + assertEquals("Malformed identifier.", error.get("message").asText()); + } + + @Test + @DisplayName("envelope: code=unsupported_parameter, param=background") + void backgroundStoreFalseUsesStructuredEnvelope() throws Exception { + TestResponse resp = post("/responses", + "{\"input\": \"x\", \"model\": \"test\", \"background\": true, \"store\": false}"); + assertEquals(400, resp.statusCode()); + JsonNode error = MAPPER.readTree(resp.body()).get("error"); + assertNotNull(error); + assertEquals("invalid_request_error", error.get("type").asText()); + assertEquals("unsupported_parameter", error.get("code").asText()); + assertEquals("background", error.get("param").asText()); + } + + @Test + @DisplayName("Cancel-on-synchronous envelope: code=invalid_request_error, no param") + void cancelSyncUsesStructuredEnvelope() throws Exception { + TestResponse createResp = post("/responses", + "{\"input\": \"sync\", \"model\": \"test-model\"}"); + String id = MAPPER.readTree(createResp.body()).get("id").asText(); + + TestResponse cancelResp = postNoBody("/responses/" + id + "/cancel"); + assertEquals(400, cancelResp.statusCode()); + JsonNode error = MAPPER.readTree(cancelResp.body()).get("error"); + assertNotNull(error); + assertEquals("invalid_request_error", error.get("type").asText()); + assertEquals("invalid_request_error", error.get("code").asText()); + assertEquals("Cannot cancel a synchronous response.", error.get("message").asText()); + assertFalse(error.has("param"), "no param expected on cancel rejections"); + } + } + + // ══════════════════════════════════════════════════════════════ + // Full lifecycle E2E + // ══════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Full lifecycle E2E") + class FullLifecycle { + + @Test + @DisplayName("Create → Get → List items → Delete → Verify gone") + void fullCrudLifecycle() throws Exception { + // 1. Create + TestResponse createResp = post("/responses", + "{\"input\": \"Full lifecycle test\", \"model\": \"test-model\"}"); + assertEquals(200, createResp.statusCode()); + String responseId = MAPPER.readTree(createResp.body()).get("id").asText(); + + // 2. Get + TestResponse getResp = get("/responses/" + responseId); + assertEquals(200, getResp.statusCode()); + assertEquals(responseId, MAPPER.readTree(getResp.body()).get("id").asText()); + + // 3. List input items + assertEquals(200, get("/responses/" + responseId + "/input_items").statusCode()); + + // 4. Delete + assertEquals(200, delete("/responses/" + responseId).statusCode()); + + // 5. Verify gone + assertEquals(404, get("/responses/" + responseId).statusCode()); + } + + @Test + @DisplayName("Multiple responses are independently stored") + void multipleIndependentResponses() throws Exception { + TestResponse resp1 = post("/responses", + "{\"input\": \"First\", \"model\": \"test\"}"); + TestResponse resp2 = post("/responses", + "{\"input\": \"Second\", \"model\": \"test\"}"); + + String id1 = MAPPER.readTree(resp1.body()).get("id").asText(); + String id2 = MAPPER.readTree(resp2.body()).get("id").asText(); + assertNotEquals(id1, id2); + + // Both retrievable + assertEquals(200, get("/responses/" + id1).statusCode()); + assertEquals(200, get("/responses/" + id2).statusCode()); + + // Delete one doesn't affect the other + delete("/responses/" + id1); + assertEquals(404, get("/responses/" + id1).statusCode()); + assertEquals(200, get("/responses/" + id2).statusCode()); + } + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/pom.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/pom.xml new file mode 100644 index 000000000000..ddd4724ea7ed --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/pom.xml @@ -0,0 +1,65 @@ + + 4.0.0 + + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../../../pom.xml + + + azure-agentserver-spring + jar + azure-agentserver-spring + + Spring Boot adapter for the Azure AI Foundry Agent Server API + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + com.microsoft.agentserver + azure-agentserver-api + + + org.springframework.boot + spring-boot-starter-web + + + org.slf4j + slf4j-api + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-context + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.jupiter + junit-jupiter + test + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/Filters.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/Filters.java new file mode 100644 index 000000000000..9045939fcb6b --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/Filters.java @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.server.spring; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Spring filters for request/response logging and CORS support. + */ +public class Filters { + + private static final boolean LOG_REQUESTS = + Boolean.parseBoolean(System.getenv().getOrDefault("CA_LOG_REQUESTS", "false")); + + private static final Logger LOGGER = LoggerFactory.getLogger(Filters.class); + + /** + * Logs incoming request method, URI, and forwarded-for header. + * Only active when the {@code CA_LOG_REQUESTS} environment variable is {@code "true"}. + */ + public static class RequestLoggingFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (LOG_REQUESTS) { + LOGGER.info("Incoming request: {} {} from {}", + request.getMethod(), + request.getRequestURI(), + request.getHeader("X-Forwarded-For")); + } + filterChain.doFilter(request, response); + } + } + + /** + * Logs outgoing response status for each request. + * Only active when the {@code CA_LOG_REQUESTS} environment variable is {@code "true"}. + */ + public static class ResponseLoggingFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + filterChain.doFilter(request, response); + if (LOG_REQUESTS) { + LOGGER.info("Outgoing response: {} for {} {}", + response.getStatus(), + request.getMethod(), + request.getRequestURI()); + } + } + } + + /** + * Adds CORS headers to all responses. + */ + public static class CorsFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + response.addHeader("Access-Control-Allow-Origin", "*"); + response.addHeader("Access-Control-Allow-Credentials", "true"); + response.addHeader("Access-Control-Allow-Headers", "origin, content-type, accept, authorization"); + response.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, HEAD"); + filterChain.doFilter(request, response); + } + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/GlobalExceptionHandler.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/GlobalExceptionHandler.java new file mode 100644 index 000000000000..787bd1117cb6 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/GlobalExceptionHandler.java @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.server.spring; + +import com.microsoft.agentserver.api.ApiError; +import com.microsoft.agentserver.api.ApiException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; + +import java.util.Map; + +/** + * Spring {@link RestControllerAdvice} that converts framework-agnostic + * {@link ApiException} instances and other common exceptions into the standard + * error envelope: {@code { "error": { "message", "type", "code",... } }}. + * + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * Handles {@link ApiException} by mapping to the appropriate HTTP status code + * and serialising the structured {@link ApiError} body. + */ + @ExceptionHandler(ApiException.class) + public ResponseEntity> handleApiException(ApiException exception) { + ApiError error = exception.getError() != null + ? exception.getError() + : ApiError.serverError("An internal error occurred."); + return ResponseEntity.status(exception.getStatusCode()) + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("error", error)); + } + + /** + * Handles 404 Not Found exceptions. + */ + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity> handleNotFound(NoHandlerFoundException exception) { + LOGGER.warn("404 Not Found: {} {}", exception.getHttpMethod(), exception.getRequestURL()); + return ResponseEntity.status(404) + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("error", ApiError.invalidRequest("Resource not found"))); + } + + /** + * Handles all unhandled exceptions as 500 Internal Server Error. + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception exception) { + LOGGER.error("Unhandled exception: {}", exception.getMessage(), exception); + return ResponseEntity.status(500) + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("error", ApiError.serverError("An internal error occurred."))); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/HealthController.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/HealthController.java new file mode 100644 index 000000000000..2e8f6255ca19 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/HealthController.java @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.server.spring; + +import com.microsoft.agentserver.api.HealthApi; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Spring MVC controller for Kubernetes-style health probe endpoints. + *

+ * Delegates to a core {@link HealthApi} implementation to determine health status. + * Returns HTTP 200 OK when healthy, HTTP 503 Service Unavailable when not. + */ +@RestController +public class HealthController { + + private final HealthApi healthApi; + + /** + * Creates a health controller with custom health checks. + * + * @param healthApi the health API implementation + */ + public HealthController(HealthApi healthApi) { + this.healthApi = healthApi; + } + + /** + * Readiness probe endpoint. + * + * @return HTTP 200 OK if ready, 503 if not + */ + @GetMapping("/readiness") + public ResponseEntity getReadiness() { + if (healthApi.isReady()) { + return ResponseEntity.ok().build(); + } + return ResponseEntity.status(503).build(); + } + + /** + * Liveness probe endpoint. + * + * @return HTTP 200 OK if alive, 503 if not + */ + @GetMapping("/liveness") + public ResponseEntity getLiveness() { + if (healthApi.isAlive()) { + return ResponseEntity.ok().build(); + } + return ResponseEntity.status(503).build(); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/InboundRequestLoggingFilter.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/InboundRequestLoggingFilter.java new file mode 100644 index 000000000000..635870c880f2 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/InboundRequestLoggingFilter.java @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.server.spring; + +import com.microsoft.agentserver.api.Observability; +import com.microsoft.agentserver.api.PlatformHeaders; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Scope; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Spring filter that provides structured inbound request logging and OpenTelemetry + * span creation for all HTTP requests. + *

+ * For every request, this filter: + *

    + *
  • Records the start time for duration calculation
  • + *
  • Extracts trace context from headers (W3C traceparent) and starts a SERVER span
  • + *
  • Sets MDC fields ({@code requestId}, {@code traceId}, {@code spanId}) for structured logging
  • + *
  • On response: logs method, path, status code, duration, and request ID
  • + *
  • Sets HTTP semantic convention attributes on the span
  • + *
+ *

+ * This filter runs after the {@link PlatformHeaderFilter} (which sets the request ID + * on the request attribute), so the MDC request ID uses the resolved value. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 10) +public class InboundRequestLoggingFilter extends OncePerRequestFilter { + + private static final Logger LOGGER = LoggerFactory.getLogger(InboundRequestLoggingFilter.class); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + long startTime = System.nanoTime(); + + // Extract headers for trace context propagation + Map headers = extractHeaders(request); + + // Start a SERVER span + String method = request.getMethod(); + String path = request.getRequestURI(); + String spanName = method + " " + path; + + Span span = Observability.startServerSpan(spanName, headers); + Scope scope = span.makeCurrent(); + + // Set MDC from span context + String traceId = span.getSpanContext().getTraceId(); + String spanId = span.getSpanContext().getSpanId(); + MDC.put("traceId", traceId); + MDC.put("spanId", spanId); + + // Set span attributes + span.setAttribute("http.request.method", method); + span.setAttribute("url.path", path); + + String clientRequestId = request.getHeader(PlatformHeaders.CLIENT_REQUEST_ID); + if (clientRequestId != null && !clientRequestId.isEmpty()) { + span.setAttribute("az.client_request_id", clientRequestId); + } + + try { + filterChain.doFilter(request, response); + } finally { + long durationMs = (System.nanoTime() - startTime) / 1_000_000; + int statusCode = response.getStatus(); + String requestId = (String) request.getAttribute(PlatformHeaders.REQUEST_ID); + + // Structured log + LOGGER.info("{} {} → {} ({}ms) [request_id={}]", + method, path, statusCode, durationMs, + requestId != null ? requestId : "-"); + + // Complete the span + Observability.setHttpAttributes(span, method, path, statusCode); + span.setAttribute("http.response.duration_ms", durationMs); + span.end(); + scope.close(); + + // Clear MDC + MDC.remove("traceId"); + MDC.remove("spanId"); + } + } + + private Map extractHeaders(HttpServletRequest request) { + Map headers = new LinkedHashMap<>(); + Enumeration headerNames = request.getHeaderNames(); + if (headerNames != null) { + while (headerNames.hasMoreElements()) { + String name = headerNames.nextElement(); + headers.put(name.toLowerCase(), request.getHeader(name)); + } + } + return headers; + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/ObjectMapperConfig.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/ObjectMapperConfig.java new file mode 100644 index 000000000000..731f44d4dca8 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/ObjectMapperConfig.java @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.server.spring; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.agentserver.api.serialization.ObjectMapperFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; + +/** + * Spring configuration that provides the pre-configured {@link ObjectMapper} + * from {@link ObjectMapperFactory} to the Spring MVC stack. + */ +@Configuration +public class ObjectMapperConfig { + + /** + * Provides the shared {@link ObjectMapper} as a Spring bean. + */ + @Bean + public ObjectMapper objectMapper() { + return ObjectMapperFactory.getObjectMapper(); + } + + /** + * Configures Spring's HTTP message converter to use the shared {@link ObjectMapper}. + */ + @Bean + public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter(ObjectMapper objectMapper) { + return new MappingJackson2HttpMessageConverter(objectMapper); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/PlatformHeaderFilter.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/PlatformHeaderFilter.java new file mode 100644 index 000000000000..2ca6a5066e8e --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/PlatformHeaderFilter.java @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.server.spring; + +import com.microsoft.agentserver.api.PlatformHeaders; +import com.microsoft.agentserver.api.AgentServerVersion; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +/** + * Spring filter that implements platform header handling for all HTTP responses: + *

    + *
  • {@code x-request-id} — echoes the client-provided request ID or generates one. + * Also placed in SLF4J MDC for structured logging correlation.
  • + *
  • {@code x-platform-server} — identifies the server SDK stack + * (hosting version, protocol versions, language, runtime).
  • + *
+ *

+ * This filter runs at high priority to ensure headers are set before any + * response body is written. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class PlatformHeaderFilter extends OncePerRequestFilter { + + private static final Logger LOGGER = LoggerFactory.getLogger(PlatformHeaderFilter.class); + private static final String MDC_REQUEST_ID = "requestId"; + + /** + * Request attribute the controller publishes for the filter to echo. + */ + public static final String SESSION_ID_ATTR = "agent_session_id"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + // Resolve request ID: use client-provided header, or generate a new one + String requestId = request.getHeader(PlatformHeaders.REQUEST_ID); + if (requestId == null || requestId.isBlank()) { + requestId = UUID.randomUUID().toString(); + } + + // Set on response immediately (before body write) + response.setHeader(PlatformHeaders.REQUEST_ID, requestId); + response.setHeader(PlatformHeaders.SERVER_VERSION, AgentServerVersion.getInstance().getHeaderValue()); + + // Store in request attribute for downstream use (e.g., ResponseContext building) + request.setAttribute(PlatformHeaders.REQUEST_ID, requestId); + + // Add to MDC for structured logging correlation + MDC.put(MDC_REQUEST_ID, requestId); + try { + filterChain.doFilter(request, response); + // after the controller runs, echo the resolved agent_session_id if it + // published one to the request attribute. Set on response only if not yet + // committed (typical for non-SSE; SSE controllers set the header inline). + Object sid = request.getAttribute(SESSION_ID_ATTR); + if (sid instanceof String s && !s.isEmpty() && !response.isCommitted()) { + response.setHeader(PlatformHeaders.SESSION_ID, s); + } + } finally { + MDC.remove(MDC_REQUEST_ID); + } + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/ResponsesController.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/ResponsesController.java new file mode 100644 index 000000000000..dd9a35b6c2db --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/ResponsesController.java @@ -0,0 +1,324 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.server.spring; + +import com.microsoft.agentserver.api.ApiException; +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.AgentServerResponseItemList; +import com.microsoft.agentserver.api.CreateResponse; +import com.microsoft.agentserver.api.IsolationContext; +import com.microsoft.agentserver.api.PlatformHeaders; +import com.microsoft.agentserver.api.RequestMetadata; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseStreamReplay; +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.api.serialization.ObjectMapperFactory; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Collections; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Spring MVC controller that exposes the {@link ResponsesApi} as HTTP endpoints. + *

+ * This class handles all Spring-specific concerns (annotations, SSE transport, + * ResponseEntity wrapping) and delegates business logic to the core {@link ResponsesApi}. + */ +@RestController +@RequestMapping("/responses") +public class ResponsesController { + + private static final Logger LOGGER = LoggerFactory.getLogger(ResponsesController.class); + + private final ResponsesApi responsesApi; + + public ResponsesController(ResponsesApi responsesApi) { + this.responsesApi = responsesApi; + } + + /** + * Create a model response (non-streaming). + */ + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createResponse( + @RequestBody AgentServerCreateResponse createResponse, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) throws ApiException { + RequestMetadata metadata = extractMetadata(httpRequest); + CreateResponse result = responsesApi.createResponse(createResponse, metadata); + publishSessionId(httpRequest, httpResponse, result.response()); + return ResponseEntity.ok(result); + } + + /** + * Create a streaming model response (SSE). + */ + @PostMapping(path = "/streaming", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter createStreamingResponse( + @RequestBody AgentServerCreateResponse createResponse, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) throws ApiException { + + RequestMetadata metadata = extractMetadata(httpRequest); + ResponseEventStream stream = responsesApi.createStreamingResponse(createResponse, metadata); + // Snapshot the response ID so we can signal client disconnect. + final String responseId = stream.getResponse() != null ? stream.getResponse().id() : null; + // stamp the resolved session ID on the response BEFORE the SSE body + // commits the response. + publishSessionId(httpRequest, httpResponse, stream.getResponse()); + + // Timeout of 0 means no timeout — the stream ends when we call complete(). + SseEmitter emitter = new SseEmitter(0L); + + // detect client disconnect (timeout/error/early-completion mid-stream) and + // signal it to the API so the response winds down to `cancelled`. signalClientDisconnected + // is a no-op for background responses and once the natural terminal has fired. + Runnable signalDisconnect = () -> { + if (responseId != null) { + responsesApi.signalClientDisconnected(responseId); + } + }; + emitter.onError(t -> signalDisconnect.run()); + emitter.onTimeout(signalDisconnect); + + stream.subscribe( + event -> { + try { + String json = ObjectMapperFactory.getObjectMapper() + .writeValueAsString(event.streamEvent()); + LOGGER.debug("SSE event [{}]: {}", event.eventName(), json); + // Per the protocol: each SSE event is written as + // `event: {type}\ndata: {json}\n\n`. No `id:` line is emitted — the + // `sequence_number` field in the JSON payload is the resumption cursor. + // + // We bypass SseEmitter.event() because its builder hard-codes + // `event:\n` / `data:\n` with no space after the colon, + // and the Foundry trajectory UI's strict SSE parser silently drops + // events that don't use the conventional `field: value` format + // (manifesting as an infinite spinner). Build the spaced frame + // ourselves and write it verbatim via send(Set). + String frame = "event: " + event.eventName() + "\ndata: " + json + "\n\n"; + emitter.send(java.util.Collections.singleton( + new ResponseBodyEmitter.DataWithMediaType(frame, MediaType.TEXT_PLAIN))); + } catch (Exception e) { + LOGGER.error("Failed to send SSE event (client may have disconnected)", e); + // treat send failure as client disconnect. + signalDisconnect.run(); + } + }, + failure -> { + // the terminal event (already emitted by the library) + // signals stream completion. There is no `[DONE]` sentinel. + LOGGER.error("Stream failed", failure); + emitter.complete(); + }, + () -> { + // no `[DONE]` sentinel — the terminal event ends the stream. + emitter.complete(); + } + ); + + return emitter; + } + + /** + * Get a model response by ID (non-streaming JSON). + *

+ * SSE replay ({@code ?stream=true}) is routed to {@link #getResponseStream} + * by {@link StreamRoutingFilter}, mirroring the POST create/streaming split. + */ + @GetMapping(path = "/{responseId}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getResponse( + @PathVariable("responseId") String responseId, + @RequestParam(value = "include", required = false) List include, + @RequestParam(value = "include_obfuscation", required = false) Boolean includeObfuscation, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) + throws ApiException { + RequestMetadata metadata = extractMetadata(httpRequest); + com.openai.models.responses.Response resp = responsesApi.getResponse(responseId, include, metadata); + publishSessionId(httpRequest, httpResponse, resp); + return ResponseEntity.ok(resp); + } + + /** + * Replay a response's SSE event stream (the API spec, + * ). Reached via {@code GET /responses/{id}?stream=true}, which + * {@link StreamRoutingFilter} forwards to this {@code /stream} sub-resource. + *

+ * Uses the same {@link SseEmitter} machinery as {@link #createStreamingResponse} + * so both endpoints share identical SSE framing semantics (no {@code id:} + * line, no {@code [DONE]} sentinel). + */ + @GetMapping(path = "/{responseId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter getResponseStream( + @PathVariable("responseId") String responseId, + @RequestParam(value = "starting_after", required = false) Integer startingAfter) + throws ApiException { + // Precondition validation happens BEFORE returning the emitter + // so ApiException (400/404) flows through the GlobalExceptionHandler. + ResponseStreamReplay replay = responsesApi.replayResponseStream(responseId, startingAfter); + + // Timeout of 0 means no timeout — the stream ends when we call complete(). + SseEmitter emitter = new SseEmitter(0L); + try { + for (ResponseStreamReplay.ReplayEvent event : replay.events()) { + // `event: {type}\ndata: {json}\n\n`, no `id:` line — + // the `sequence_number` is already present in the JSON payload. + emitter.send(SseEmitter.event() + .name(event.eventName()) + .data(event.data(), MediaType.APPLICATION_JSON)); + } + } catch (IOException e) { + LOGGER.error("Failed to send SSE replay event for response {}", responseId, e); + emitter.completeWithError(e); + return emitter; + } + // no `[DONE]` sentinel — completing the emitter ends the stream. + emitter.complete(); + return emitter; + } + + /** + * Cancel a background model response by ID. + *

+ * Per the API spec, this cancels a + * {@code background=true} response that is still {@code queued} or + * {@code in_progress}, returning HTTP 200 with the cancelled response. + * Rejections (synchronous response, terminal state, not found) surface as + * {@link ApiException} (400/404). + */ + @PostMapping(path = "/{responseId}/cancel", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity cancelResponse( + @PathVariable("responseId") String responseId, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) throws ApiException { + com.openai.models.responses.Response resp = responsesApi.cancelResponse(responseId); + publishSessionId(httpRequest, httpResponse, resp); + return ResponseEntity.ok(resp); + } + + /** + * Delete a model response by ID. + *

+ * Per the API spec, a successful delete + * returns HTTP 200 with a body of the shape + * {@code { "id": "...", "object": "response", "deleted": true }}. + */ + @DeleteMapping(path = "/{responseId}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> deleteResponse(@PathVariable("responseId") String responseId) throws ApiException { + responsesApi.deleteResponse(responseId); + Map body = new LinkedHashMap<>(); + body.put("id", responseId); + body.put("object", "response"); + body.put("deleted", true); + return ResponseEntity.ok(body); + } + + /** + * List input items for a response. + */ + @GetMapping(path = "/{responseId}/input_items", produces = MediaType.APPLICATION_JSON_VALUE) + public AgentServerResponseItemList listInputItems( + @PathVariable("responseId") String responseId, + @RequestParam(value = "limit", defaultValue = "20") Integer limit, + @RequestParam(value = "order", required = false) String order, + @RequestParam(value = "after", required = false) String after, + @RequestParam(value = "before", required = false) String before, + @RequestParam(value = "include", required = false) List include) throws ApiException { + return responsesApi.listInputItems(responseId, limit, order, after, before, include); + } + + // ── Private helpers ───────────────────────────────────────── + + /** + * Publishes the resolved {@code agent_session_id} so the + * {@link PlatformHeaderFilter} echoes it as {@code x-agent-session-id}. + * For SSE responses (where the body may already be committing), this also + * sets the header directly on the {@link HttpServletResponse}. + */ + private static void publishSessionId(HttpServletRequest request, HttpServletResponse response, + com.openai.models.responses.Response resp) { + if (resp == null) { + return; + } + com.openai.core.JsonValue val = resp._additionalProperties().get("agent_session_id"); + if (val == null) { + return; + } + @SuppressWarnings("unchecked") + java.util.Optional opt = val.asString(); + opt.filter(s -> !s.isEmpty()).ifPresent(s -> { + request.setAttribute(PlatformHeaderFilter.SESSION_ID_ATTR, s); + if (response != null && !response.isCommitted()) { + response.setHeader(PlatformHeaders.SESSION_ID, s); + } + }); + } + + /** + * Extracts platform metadata from the HTTP request headers and attributes. + * Includes isolation keys, client headers (x-client-*), query parameters, + * and the resolved request ID. + */ + private RequestMetadata extractMetadata(HttpServletRequest request) { + // Extract all headers into a case-insensitive map + Map allHeaders = new LinkedHashMap<>(); + Enumeration headerNames = request.getHeaderNames(); + if (headerNames != null) { + while (headerNames.hasMoreElements()) { + String name = headerNames.nextElement(); + allHeaders.put(name.toLowerCase(), request.getHeader(name)); + } + } + + // Isolation context from platform headers + IsolationContext isolation = IsolationContext.fromHeaders(allHeaders); + + // Client headers (x-client-* prefix) + Map clientHeaders = IsolationContext.extractClientHeaders(allHeaders); + + // Query parameters + Map queryParameters; + if (request.getParameterMap().isEmpty()) { + queryParameters = Collections.emptyMap(); + } else { + Map params = new LinkedHashMap<>(); + request.getParameterMap().forEach((key, values) -> { + if (values != null && values.length > 0) { + params.put(key, values[0]); + } + }); + queryParameters = Collections.unmodifiableMap(params); + } + + // Request ID (set by PlatformHeaderFilter) + String requestId = (String) request.getAttribute(PlatformHeaders.REQUEST_ID); + + // x-agent-response-id request header overrides the generated response ID. + String responseIdOverride = allHeaders.get(PlatformHeaders.RESPONSE_ID_OVERRIDE); + + return new RequestMetadata(isolation, clientHeaders, queryParameters, requestId, responseIdOverride); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/SpringAgentServerAdaptorService.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/SpringAgentServerAdaptorService.java new file mode 100644 index 000000000000..45bd62f70d87 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/SpringAgentServerAdaptorService.java @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.server.spring; + +import com.microsoft.agentserver.api.HealthApi; +import com.microsoft.agentserver.api.ResponsesApi; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +/** + * Spring Boot adapter service for the Azure AI Foundry Agent Server API. + *

+ * Provides static factory methods to build and run a Spring Boot application + * hosting the given {@link ResponsesApi} implementation, mirroring the + * {@code JerseyAgentServerAdaptorService} API. + * + *

Example usage

+ *
{@code
+ * SpringAgentServerAdaptorService.run(myResponsesApi);
+ * }
+ * + *

Custom port

+ *
{@code
+ * SpringAgentServerAdaptorService.run("8088", myResponsesApi);
+ * }
+ */ +public class SpringAgentServerAdaptorService { + + private static final boolean LOG_REQUESTS = + Boolean.parseBoolean(System.getenv().getOrDefault("CA_LOG_REQUESTS", "false")); + + /** + * Builds and starts a Spring Boot application hosting the given {@link ResponsesApi} + * on the default port (8088). + * + * @param responsesApi the ResponsesApi instance to be served + * @return the running Spring application context + */ + public static ConfigurableApplicationContext run(ResponsesApi responsesApi) { + return run("8088", responsesApi); + } + + /** + * Builds and starts a Spring Boot application hosting the given {@link ResponsesApi} + * on the specified port. + * + * @param port the port to listen on (e.g. "8088") + * @param responsesApi the ResponsesApi instance to be served + * @return the running Spring application context + */ + public static ConfigurableApplicationContext run(String port, ResponsesApi responsesApi) { + return run(port, responsesApi, new HealthApi() { + }); + } + + /** + * Builds and starts a Spring Boot application hosting the given {@link ResponsesApi} + * and {@link HealthApi} on the specified port. + * + * @param port the port to listen on (e.g. "8088") + * @param responsesApi the ResponsesApi instance to be served + * @param healthApi the HealthApi instance for health probes + * @return the running Spring application context + */ + public static ConfigurableApplicationContext run(String port, ResponsesApi responsesApi, HealthApi healthApi) { + SpringApplication app = new SpringApplication(AgentServerAutoConfiguration.class); + app.setDefaultProperties(java.util.Map.of( + "server.port", port, + "server.address", "0.0.0.0", + "spring.mvc.throw-exception-if-no-handler-found", "true" + )); + + // Register the ResponsesApi and HealthApi beans programmatically + app.addInitializers(context -> { + context.getBeanFactory().registerSingleton("responsesApi", responsesApi); + context.getBeanFactory().registerSingleton("healthApi", healthApi); + }); + + return app.run(); + } + + /** + * Spring Boot auto-configuration that registers all controllers, filters, + * and exception handlers for the agent server API. + */ + @SpringBootApplication + @Configuration + static class AgentServerAutoConfiguration { + + @Bean + public ResponsesController responsesController(ResponsesApi responsesApi) { + return new ResponsesController(responsesApi); + } + + @Bean + public HealthController healthController(HealthApi healthApi) { + return new HealthController(healthApi); + } + + @Bean + public GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Bean + public ObjectMapperConfig objectMapperConfig() { + return new ObjectMapperConfig(); + } + + @Bean + public StreamRoutingFilter streamRoutingFilter() { + return new StreamRoutingFilter(); + } + + @Bean + public SseHeaderFilter sseHeaderFilter() { + return new SseHeaderFilter(); + } + + @Bean + public FilterRegistrationBean corsFilter() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(new Filters.CorsFilter()); + registration.addUrlPatterns("/*"); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 2); + return registration; + } + + @Bean + public FilterRegistrationBean requestLoggingFilter() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(new Filters.RequestLoggingFilter()); + registration.addUrlPatterns("/*"); + registration.setEnabled(LOG_REQUESTS); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 3); + return registration; + } + + @Bean + public FilterRegistrationBean responseLoggingFilter() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(new Filters.ResponseLoggingFilter()); + registration.addUrlPatterns("/*"); + registration.setEnabled(LOG_REQUESTS); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 4); + return registration; + } + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/SseHeaderFilter.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/SseHeaderFilter.java new file mode 100644 index 000000000000..4d0601ef68f2 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/SseHeaderFilter.java @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.server.spring; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Spring filter that adds anti-buffering headers to SSE responses. + *

+ * Reverse proxies (nginx, Azure Front Door, Foundry proxy, etc.) buffer + * response bodies by default. For Server-Sent Events to stream correctly + * through these proxies, the following headers must be present: + *

    + *
  • {@code X-Accel-Buffering: no} — disables nginx proxy buffering
  • + *
  • {@code Cache-Control: no-cache} — prevents intermediate caching
  • + *
  • {@code Connection: keep-alive} — maintains the persistent connection
  • + *
+ * Without these headers, the proxy accumulates the entire SSE stream before + * forwarding it to the client, causing the client to see an infinite spinner. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class SseHeaderFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + filterChain.doFilter(request, response); + + // After the response is committed, check if it's an SSE response + String contentType = response.getContentType(); + if (contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) { + // Per the SSE Response Headers contract, declare an explicit charset. + response.setHeader("Content-Type", "text/event-stream; charset=utf-8"); + response.setHeader("X-Accel-Buffering", "no"); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Connection", "keep-alive"); + } + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/StreamRoutingFilter.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/StreamRoutingFilter.java new file mode 100644 index 000000000000..c7373110db1e --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring/src/main/java/com/microsoft/agentserver/server/spring/StreamRoutingFilter.java @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.server.spring; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * Spring filter that reroutes requests to dedicated streaming sub-resources, + * mirroring the protocol's stream/non-stream split: + *
    + *
  • {@code POST /responses} with {@code "stream": true} in the body → + * {@code POST /responses/streaming}.
  • + *
  • {@code GET /responses/{id}?stream=true} (SSE replay) → + * {@code GET /responses/{id}/stream}.
  • + *
+ */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class StreamRoutingFilter extends OncePerRequestFilter { + + private static final Logger LOGGER = LoggerFactory.getLogger(StreamRoutingFilter.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * Maximum request body size (in bytes) the routing filter will buffer to inspect + * the {@code "stream"} flag. Requests exceeding this limit are rejected with + * HTTP 413 to prevent out-of-memory conditions. + */ + private static final int MAX_BODY_SIZE = 1024 * 1024; // 1 MB + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String path = request.getRequestURI(); + String method = request.getMethod(); + + // GET /responses/{id}?stream=true → SSE replay sub-resource. + if ("GET".equalsIgnoreCase(method)) { + if (path.matches("/responses/[^/]+") && "true".equalsIgnoreCase(request.getParameter("stream"))) { + LOGGER.debug("Routing filter: GET stream replay → {}/stream", path); + request.getRequestDispatcher(path + "/stream").forward(request, response); + return; + } + filterChain.doFilter(request, response); + return; + } + + // Only inspect POST /responses (exact match, not sub-paths) + if (!"POST".equalsIgnoreCase(method) || !"/responses".equals(path)) { + filterChain.doFilter(request, response); + return; + } + + // Read the body bytes and wrap the request so downstream can re-read + byte[] bodyBytes; + try { + bodyBytes = request.getInputStream().readNBytes(MAX_BODY_SIZE + 1); + } catch (IOException e) { + LOGGER.warn("Failed to read request body for stream routing detection"); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Unable to read request body"); + return; + } + + if (bodyBytes.length > MAX_BODY_SIZE) { + LOGGER.warn("Request body exceeds maximum size of {} bytes", MAX_BODY_SIZE); + response.sendError(413, "Request body too large"); + return; + } + + // Wrap the request to allow re-reading the body + CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(request, bodyBytes); + + try { + JsonNode jsonNode = MAPPER.readTree(bodyBytes); + boolean stream = jsonNode.has("stream") && jsonNode.get("stream").asBoolean(false); + + LOGGER.debug("Routing filter: stream={}, contentLength={}", stream, bodyBytes.length); + + if (stream) { + // Forward to the streaming endpoint + request.getRequestDispatcher("/responses/streaming").forward(wrappedRequest, response); + return; + } + } catch (IOException e) { + LOGGER.warn("Failed to parse request body as JSON for stream routing detection"); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid JSON in request body"); + return; + } + + filterChain.doFilter(wrappedRequest, response); + } + + /** + * Wraps an {@link HttpServletRequest} so that the body can be read multiple times. + */ + private static class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + + private final byte[] cachedBody; + + CachedBodyHttpServletRequest(HttpServletRequest request, byte[] body) { + super(request); + this.cachedBody = body; + } + + @Override + public jakarta.servlet.ServletInputStream getInputStream() { + return new CachedBodyServletInputStream(cachedBody); + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); + } + + @Override + public int getContentLength() { + return cachedBody.length; + } + + @Override + public long getContentLengthLong() { + return cachedBody.length; + } + } + + /** + * A {@link jakarta.servlet.ServletInputStream} backed by a byte array. + */ + private static class CachedBodyServletInputStream extends jakarta.servlet.ServletInputStream { + + private final ByteArrayInputStream delegate; + + CachedBodyServletInputStream(byte[] body) { + this.delegate = new ByteArrayInputStream(body); + } + + @Override + public boolean isFinished() { + return delegate.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(jakarta.servlet.ReadListener readListener) { + throw new UnsupportedOperationException("setReadListener is not supported"); + } + + @Override + public int read() { + return delegate.read(); + } + + @Override + public int read(byte[] b, int off, int len) { + return delegate.read(b, off, len); + } + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/Dockerfile b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/Dockerfile new file mode 100644 index 000000000000..d254df2d188d --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/Dockerfile @@ -0,0 +1,16 @@ +FROM eclipse-temurin:25-jdk + +WORKDIR /app + +# Download the Application Insights Java agent +ADD https://github.com/microsoft/ApplicationInsights-Java/releases/download/3.6.2/applicationinsights-agent-3.6.2.jar applicationinsights-agent.jar + +COPY target/azure-agentserver-langchain4j-sample-*-jar-with-dependencies.jar app.jar +COPY applicationinsights.json applicationinsights.json +COPY run.sh /app/run.sh +RUN chmod +x /app/run.sh + +EXPOSE 8088 + +ENTRYPOINT ["/app/run.sh"] + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/applicationinsights.json b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/applicationinsights.json new file mode 100644 index 000000000000..8f728cd6192d --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/applicationinsights.json @@ -0,0 +1,14 @@ +{ + "sampling": { + "percentage": 100 + }, + "instrumentation": { + "logging": { + "level": "DEBUG" + } + }, + "selfDiagnostics": { + "level": "DEBUG" + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/docker-compose.yml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/docker-compose.yml new file mode 100644 index 000000000000..19a515964b6b --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/docker-compose.yml @@ -0,0 +1,12 @@ +services: + echo-sample: + build: + context: . + dockerfile: Dockerfile + image: ${REGISTRY:-}lc4jsample:latest + + ports: + - "8088:8088" + + env_file: + - ../../.env diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/pom.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/pom.xml new file mode 100644 index 000000000000..b3830b08815b --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/pom.xml @@ -0,0 +1,130 @@ + + 4.0.0 + + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + azure-agentserver-langchain4j-sample + jar + azure-agentserver-langchain4j-sample + + LangChain4j sample for the Azure AI Foundry Agent Server Server + + + + + io.quarkus + quarkus-bom + ${quarkus.version} + pom + import + + + + + + + org.glassfish.jersey.containers + jersey-container-grizzly2-http + + + org.glassfish.jersey.inject + jersey-hk2 + + + org.glassfish.jersey.media + jersey-media-json-jackson + + + org.glassfish.jersey.media + jersey-media-sse + + + com.microsoft.agentserver + azure-agentserver-jersey + + + com.microsoft.agentserver + azure-agentserver-langchain4j + + + dev.langchain4j + langchain4j-azure-open-ai + + + + com.azure + azure-core-http-netty + + + + + io.netty + netty-resolver-dns + + + + + io.netty + netty-handler-proxy + + + + + org.slf4j + slf4j-simple + runtime + + + + + src/main/java + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + true + + + + build + generate-code + generate-code-tests + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + ${maven-assembly-plugin.version} + + + + Main + + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/run-sample.sh b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/run-sample.sh new file mode 100755 index 000000000000..e5fd5f32e386 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/run-sample.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +../../mvnw package -DskipTests + +# Load environment variables from .env +set -a +source ../../.env +set +a + +export REGISTRY=${REGISTRY:-} + +docker compose build --no-cache +docker compose up --force-recreate -d + +# wait for the container to be ready +sleep 20 + +curl -vvv -X POST http://localhost:8088/responses \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "input": "What is 12 + 13", + "model": "gpt-4o" + }' -o - + +docker compose down diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/run.sh b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/run.sh new file mode 100644 index 000000000000..8cd76146c77b --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/run.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Import the Foundry vNext egress-proxy CA cert into Java's truststore so that +# outbound HTTPS calls (Azure OpenAI, Foundry storage API, MSI, etc.) succeed +# from inside an Azure Agent Server. The cert is mounted by the platform at +# a well-known path when running under the vNext "ADC" hosting environment; +# absent locally. +ADC_CERT="/etc/ssl/certs/adc-egress-proxy-ca.crt" +if [ -f "$ADC_CERT" ]; then + echo "Importing ADC egress proxy CA cert into Java truststore..." + cat "$ADC_CERT" >> /etc/ssl/certs/ca-certificates.crt 2>/dev/null || true + JAVA_CACERTS="$JAVA_HOME/lib/security/cacerts" + if [ -f "$JAVA_CACERTS" ]; then + keytool -importcert -noprompt -trustcacerts \ + -alias adc-egress-proxy \ + -file "$ADC_CERT" \ + -keystore "$JAVA_CACERTS" \ + -storepass changeit 2>/dev/null || true + echo "ADC CA cert imported into Java truststore." + else + echo "WARNING: Java cacerts not found at $JAVA_CACERTS" + fi +else + echo "No ADC egress proxy CA cert found (not running in vNext)." +fi + +exec java \ + ${APPLICATIONINSIGHTS_CONNECTION_STRING:+-javaagent:/app/applicationinsights-agent.jar} \ + -jar /app/app.jar diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/GeneralAgent.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/GeneralAgent.java new file mode 100644 index 000000000000..3e7cb6180b22 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/GeneralAgent.java @@ -0,0 +1,26 @@ +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +import java.util.List; + +/** + * Fallback sub-agent that handles any general question for which no + * specialized sub-agent applies. Keeps the supervisor from returning an empty + * response when the user asks something outside the math domain. + */ +public interface GeneralAgent { + + @SystemMessage(""" + You are a helpful assistant that is part of an agent for answering maths questions. Confine your answers to + answering questions about maths and what the agent can do + """) + @UserMessage(""" + Conversation: + {{messages}} + """) + @Agent("Answers general questions when no specialised agent applies") + String answer(@V("messages") List messages); +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/Init.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/Init.java new file mode 100644 index 000000000000..8669ec495273 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/Init.java @@ -0,0 +1,54 @@ +import com.microsoft.agentserver.api.langchain4j.SupervisorAgentWithMemory; +import dev.langchain4j.agentic.AgenticServices; +import dev.langchain4j.agentic.supervisor.SupervisorContextStrategy; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.azure.AzureOpenAiChatModel; +import dev.langchain4j.model.chat.ChatModel; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Alternative; +import jakarta.ws.rs.Produces; + +@Alternative +@Priority(1) +@ApplicationScoped +public class Init { + private static final String DEPLOYMENT_NAME = System.getenv().getOrDefault("AZURE_DEPLOYMENT_NAME", "gpt-4o"); + private static final String AZURE_API_KEY = System.getenv("AZURE_API_KEY"); + private static final String AZURE_ENDPOINT = System.getenv("AZURE_ENDPOINT"); + + @Produces + @Alternative + @Priority(1) + @ApplicationScoped + public SupervisorAgentWithMemory createAgent() { + ChatModel model = AzureOpenAiChatModel.builder() + .deploymentName(DEPLOYMENT_NAME) + .apiKey(AZURE_API_KEY) + .endpoint(AZURE_ENDPOINT) + .build(); + + MathsAgent mathsAgent = AgenticServices + .agentBuilder(MathsAgent.class) + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) + .tools(new MathsTools()) + .chatModel(model) + .outputKey("result") + .build(); + + GeneralAgent generalAgent = AgenticServices + .agentBuilder(GeneralAgent.class) + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) + .chatModel(model) + .outputKey("result") + .build(); + + return AgenticServices + .supervisorBuilder(SupervisorAgentWithMemory.class) + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) + .chatModel(model) + .subAgents(mathsAgent, generalAgent) + .contextGenerationStrategy(SupervisorContextStrategy.CHAT_MEMORY) + .build(); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/Main.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/Main.java new file mode 100644 index 000000000000..4ce83154e6e7 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/Main.java @@ -0,0 +1,14 @@ +import com.microsoft.agentserver.api.langchain4j.Langchain4jResponsesHandler; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; + +public class Main { + public static void main(String[] args) throws InterruptedException { + JerseyAgentServerAdaptorService.buildAgent( + Langchain4jResponsesHandler.builder() + .supervisorAgent(new Init().createAgent()) + .build() + ); + + Thread.sleep(Long.MAX_VALUE); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/MathsAgent.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/MathsAgent.java new file mode 100644 index 000000000000..fd1bab3b710e --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/MathsAgent.java @@ -0,0 +1,29 @@ +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +import java.util.List; + +public interface MathsAgent { + + /** + * AI agent for solving mathematical queries. + *

+ * Uses LangChain4j's @Agent annotation to define an AI agent that can leverage + * available tools to solve math problems. The @UserMessage template defines the + * prompt sent to the language model, and @V binds method parameters to template + * placeholders. + */ + @SystemMessage(""" + You are a helpful bot that uses the tools available to solve simple maths queries. + """) + @UserMessage(""" + The query is: + {{messages}} + """) + @Agent("Solves a simple math problem using the available tools") + public String solveQuery(@V("messages") List messages); + +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/MathsTools.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/MathsTools.java new file mode 100644 index 000000000000..22c5aadd5635 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/java/MathsTools.java @@ -0,0 +1,41 @@ +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class MathsTools { + + @Tool(name = "add", value = "Adds two numbers together") + public Integer add(@P("a") Integer a, @P("b") Integer b) { + return a + b; + } + + @Tool(name = "subtract", value = "Subtracts b from a") + public Integer subtract(@P("a") Integer a, @P("b") Integer b) { + return a - b; + } + + @Tool(name = "multiply", value = "Multiplies two numbers together") + public Integer multiply(@P("a") Integer a, @P("b") Integer b) { + return a * b; + } + + // Something the LLM can't answer itself, this makes sure the LLM uses the tool + @Tool(name = "hash", value = "Applies the # operator to a number") + public String hash(@P("a") Integer a) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(a.toString().getBytes()); + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) hexString.append('0'); + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available", e); + } + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/resources/META-INF/beans.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/resources/META-INF/beans.xml new file mode 100644 index 000000000000..0eae218a41d2 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/resources/META-INF/beans.xml @@ -0,0 +1,7 @@ + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/resources/application.properties b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/resources/application.properties new file mode 100644 index 000000000000..e659f20a8f6b --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/main/resources/application.properties @@ -0,0 +1,2 @@ +quarkus.index-dependency.openai.group-id=com.openai +quarkus.index-dependency.openai.artifact-id=openai-java-core diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/test/java/JerseyAgentServerService.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/test/java/JerseyAgentServerService.java new file mode 100644 index 000000000000..3a53c79a528c --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/test/java/JerseyAgentServerService.java @@ -0,0 +1,45 @@ +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.api.langchain4j.Langchain4jResponsesHandler; +import dev.langchain4j.agentic.UntypedAgent; +import jakarta.enterprise.context.ApplicationScoped; +import org.glassfish.grizzly.http.server.HttpServer; +import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory; +import org.glassfish.jersey.inject.hk2.AbstractBinder; +import org.glassfish.jersey.server.ResourceConfig; + +import java.net.URI; + +public class JerseyAgentServerService { + + public static class AgentBinder extends AbstractBinder { + + private final UntypedAgent supervisorAgent; + + public AgentBinder(UntypedAgent supervisorAgent) { + this.supervisorAgent = supervisorAgent; + } + + @Override + protected void configure() { + Langchain4jResponsesHandler handler = new Langchain4jResponsesHandler(supervisorAgent, null); + ResponsesApi res = ResponsesApi.builder() + .responseHandler(handler) + .build(); + + bind(res).to(ResponsesApi.class).in(ApplicationScoped.class); + } + } + + public static HttpServer buildAgent(UntypedAgent agent) { + final ResourceConfig rc = new ResourceConfig() + .register(new AgentBinder(agent)) + .packages(true, "com.microsoft"); + + final String BASE_URI = "http://0.0.0.0:8080/"; + return build(URI.create(BASE_URI), rc); + } + + public static HttpServer build(URI uri, ResourceConfig rc) { + return GrizzlyHttpServerFactory.createHttpServer(uri, rc); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/test/java/Main.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/test/java/Main.java new file mode 100644 index 000000000000..3b288696743f --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-sample/src/test/java/Main.java @@ -0,0 +1,29 @@ +import dev.langchain4j.agentic.AgenticServices; +import dev.langchain4j.agentic.UntypedAgent; +import dev.langchain4j.model.azure.AzureOpenAiChatModel; + +public class Main { + + public static void main(String[] args) throws InterruptedException { + MathsAgent mathsAgent = AgenticServices + .agentBuilder(MathsAgent.class) + .tools(new MathsTools()) + .chatModel(AzureOpenAiChatModel.builder() + .deploymentName("gpt-4o") + .apiKey(System.getenv("AZURE_API_KEY")) + .endpoint(System.getenv("AZURE_ENDPOINT")) + .build()) + .outputKey("result") + .build(); + + UntypedAgent agent = AgenticServices + .sequenceBuilder(UntypedAgent.class) + .subAgents(mathsAgent) + .outputKey("result") + .build(); + + JerseyAgentServerService.buildAgent(agent); + + Thread.currentThread().join(); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/Dockerfile b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/Dockerfile new file mode 100644 index 000000000000..7b0b0ee6f0a7 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/Dockerfile @@ -0,0 +1,17 @@ +FROM eclipse-temurin:25-jdk + +WORKDIR /app + +# Download the Application Insights Java agent +ADD https://github.com/microsoft/ApplicationInsights-Java/releases/download/3.6.2/applicationinsights-agent-3.6.2.jar applicationinsights-agent.jar + +COPY target/azure-agentserver-langchain4j-spring-sample-*-SNAPSHOT.jar app.jar +COPY applicationinsights.json applicationinsights.json +COPY run.sh /app/run.sh +RUN chmod +x /app/run.sh + +EXPOSE 8088 + +ENTRYPOINT ["/app/run.sh"] + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/applicationinsights.json b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/applicationinsights.json new file mode 100644 index 000000000000..b1b55be1acb3 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/applicationinsights.json @@ -0,0 +1,15 @@ +{ + "sampling": { + "percentage": 100 + }, + "instrumentation": { + "logging": { + "level": "DEBUG" + } + }, + "selfDiagnostics": { + "level": "DEBUG" + } +} + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/docker-compose.yml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/docker-compose.yml new file mode 100644 index 000000000000..5fdbe7757d6c --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/docker-compose.yml @@ -0,0 +1,13 @@ +services: + echo-sample: + build: + context: . + dockerfile: Dockerfile + image: ${REGISTRY:-}lc4jspringsample:latest + + ports: + - "8088:8088" + + env_file: + - ../../.env + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/pom.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/pom.xml new file mode 100644 index 000000000000..5d57315a2b02 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/pom.xml @@ -0,0 +1,89 @@ + + 4.0.0 + + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + azure-agentserver-langchain4j-spring-sample + jar + azure-agentserver-langchain4j-spring-sample + + Spring Boot sample for the Azure AI Foundry Agent Server API using LangChain4j. + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + com.microsoft.agentserver + azure-agentserver-langchain4j + + + + com.microsoft.agentserver + azure-agentserver-spring + + + + org.springframework.boot + spring-boot-starter-web + + + com.azure + azure-identity + + + + dev.langchain4j + langchain4j-azure-open-ai + + + + + io.netty + netty-resolver-dns + + + + + io.netty + netty-handler-proxy + + + + + src/main/java + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + Main + + + + + repackage + + + + + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/run-sample.sh b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/run-sample.sh new file mode 100755 index 000000000000..adf18d84941d --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/run-sample.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +../../mvnw package -DskipTests + +# Load environment variables from .env +set -a +source ../../.env +set +a + +export REGISTRY=${REGISTRY:-} + +docker-compose build --no-cache +docker-compose up --force-recreate -d + +# wait for the container to be ready +sleep 20 + +curl -vvv -X POST http://localhost:8088/responses \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "input": "What is #1234", + "model": "gpt-4o" + }' -o - + +docker-compose down + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/run.sh b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/run.sh new file mode 100644 index 000000000000..8cd76146c77b --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/run.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Import the Foundry vNext egress-proxy CA cert into Java's truststore so that +# outbound HTTPS calls (Azure OpenAI, Foundry storage API, MSI, etc.) succeed +# from inside an Azure Agent Server. The cert is mounted by the platform at +# a well-known path when running under the vNext "ADC" hosting environment; +# absent locally. +ADC_CERT="/etc/ssl/certs/adc-egress-proxy-ca.crt" +if [ -f "$ADC_CERT" ]; then + echo "Importing ADC egress proxy CA cert into Java truststore..." + cat "$ADC_CERT" >> /etc/ssl/certs/ca-certificates.crt 2>/dev/null || true + JAVA_CACERTS="$JAVA_HOME/lib/security/cacerts" + if [ -f "$JAVA_CACERTS" ]; then + keytool -importcert -noprompt -trustcacerts \ + -alias adc-egress-proxy \ + -file "$ADC_CERT" \ + -keystore "$JAVA_CACERTS" \ + -storepass changeit 2>/dev/null || true + echo "ADC CA cert imported into Java truststore." + else + echo "WARNING: Java cacerts not found at $JAVA_CACERTS" + fi +else + echo "No ADC egress proxy CA cert found (not running in vNext)." +fi + +exec java \ + ${APPLICATIONINSIGHTS_CONNECTION_STRING:+-javaagent:/app/applicationinsights-agent.jar} \ + -jar /app/app.jar diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/GeneralAgent.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/GeneralAgent.java new file mode 100644 index 000000000000..3e7cb6180b22 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/GeneralAgent.java @@ -0,0 +1,26 @@ +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +import java.util.List; + +/** + * Fallback sub-agent that handles any general question for which no + * specialized sub-agent applies. Keeps the supervisor from returning an empty + * response when the user asks something outside the math domain. + */ +public interface GeneralAgent { + + @SystemMessage(""" + You are a helpful assistant that is part of an agent for answering maths questions. Confine your answers to + answering questions about maths and what the agent can do + """) + @UserMessage(""" + Conversation: + {{messages}} + """) + @Agent("Answers general questions when no specialised agent applies") + String answer(@V("messages") List messages); +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/Init.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/Init.java new file mode 100644 index 000000000000..e04e553ba9b8 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/Init.java @@ -0,0 +1,56 @@ +import com.microsoft.agentserver.api.FoundryEnvironment; +import com.microsoft.agentserver.api.langchain4j.SupervisorAgentWithMemory; +import dev.langchain4j.agentic.AgenticServices; +import dev.langchain4j.agentic.supervisor.SupervisorContextStrategy; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.azure.AzureOpenAiChatModel; +import dev.langchain4j.model.chat.ChatModel; + +public class Init { + public SupervisorAgentWithMemory createAgent() { + String openAiEndpoint = FoundryEnvironment.OPENAI_ENDPOINT; + if (openAiEndpoint == null || openAiEndpoint.isBlank()) { + throw new IllegalStateException("No endpoint configured. Set FOUNDRY_PROJECT_ENDPOINT or AZURE_OPENAI_ENDPOINT."); + } + + ChatModel model = buildModel(openAiEndpoint); + + MathsAgent mathsAgent = AgenticServices + .agentBuilder(MathsAgent.class) + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) + .tools(new MathsTools()) + .chatModel(model) + .outputKey("result") + .build(); + + GeneralAgent generalAgent = AgenticServices + .agentBuilder(GeneralAgent.class) + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) + .chatModel(model) + .outputKey("result") + .build(); + + return AgenticServices + .supervisorBuilder(SupervisorAgentWithMemory.class) + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) + .chatModel(model) + .subAgents(mathsAgent, generalAgent) + .contextGenerationStrategy(SupervisorContextStrategy.CHAT_MEMORY) + .build(); + } + + private static AzureOpenAiChatModel buildModel(String openAiEndpoint) { + AzureOpenAiChatModel.Builder builder = AzureOpenAiChatModel.builder() + .deploymentName(FoundryEnvironment.MODEL_DEPLOYMENT_NAME); + + String nonAzureApiKey = System.getenv("AZURE_CLIENT_KEY"); + if (nonAzureApiKey != null && !nonAzureApiKey.isBlank()) { + builder.nonAzureApiKey(nonAzureApiKey); + } else { + builder.tokenCredential(FoundryEnvironment.resolveCredential()); + } + return builder + .endpoint(openAiEndpoint) + .build(); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/Main.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/Main.java new file mode 100644 index 000000000000..0142029a71c9 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/Main.java @@ -0,0 +1,13 @@ +import com.microsoft.agentserver.api.langchain4j.Langchain4jResponsesHandler; +import com.microsoft.agentserver.server.spring.SpringAgentServerAdaptorService; + +public class Main { + public static void main(String[] args) { + SpringAgentServerAdaptorService.run( + Langchain4jResponsesHandler.builder() + .supervisorAgent(new Init().createAgent()) + .build() + ); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/MathsAgent.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/MathsAgent.java new file mode 100644 index 000000000000..376c80a2800f --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/MathsAgent.java @@ -0,0 +1,26 @@ +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +import java.util.List; + +public interface MathsAgent { + /** + * AI agent for solving mathematical queries. + *

+ * Uses LangChain4j's @Agent annotation to define an AI agent that can leverage + * available tools to solve math problems. The @UserMessage template defines the + * prompt sent to the language model, and @V binds method parameters to template + * placeholders. + */ + @UserMessage(""" + You are a helpful bot that uses the tools available to solve simple maths queries. + + The query is: + {{messages}} + """) + @Agent("Solves a simple math problem using the available tools") + public String solveQuery(@V("messages") List messages); +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/MathsTools.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/MathsTools.java new file mode 100644 index 000000000000..5ba42e0faa91 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/java/MathsTools.java @@ -0,0 +1,42 @@ +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class MathsTools { + + @Tool(name = "add", value = "Adds two numbers together") + public Integer add(@P("a") Integer a, @P("b") Integer b) { + return a + b; + } + + @Tool(name = "subtract", value = "Subtracts b from a") + public Integer subtract(@P("a") Integer a, @P("b") Integer b) { + return a - b; + } + + @Tool(name = "multiply", value = "Multiplies two numbers together") + public Integer multiply(@P("a") Integer a, @P("b") Integer b) { + return a * b; + } + + // Something the LLM can't answer itself, this makes sure the LLM uses the tool + @Tool(name = "hash", value = "Applies the # operator to a number") + public String hash(@P("a") Integer a) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(a.toString().getBytes()); + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) hexString.append('0'); + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available", e); + } + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/resources/application.properties b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/resources/application.properties new file mode 100644 index 000000000000..0f05e0be012a --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/main/resources/application.properties @@ -0,0 +1,3 @@ +server.port=8088 +server.address=0.0.0.0 + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/test/java/Main.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/test/java/Main.java new file mode 100644 index 000000000000..0313b3a1c79e --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/test/java/Main.java @@ -0,0 +1,28 @@ +import dev.langchain4j.agentic.AgenticServices; +import dev.langchain4j.agentic.UntypedAgent; +import dev.langchain4j.model.azure.AzureOpenAiChatModel; + +public class Main { + + public static void main(String[] args) { + MathsAgent mathsAgent = AgenticServices + .agentBuilder(MathsAgent.class) + .tools(new MathsTools()) + .chatModel(AzureOpenAiChatModel.builder() + .deploymentName("gpt-4o") + .apiKey(System.getenv("AZURE_API_KEY")) + .endpoint(System.getenv("AZURE_ENDPOINT")) + .build()) + .outputKey("result") + .build(); + + UntypedAgent agent = AgenticServices + .sequenceBuilder(UntypedAgent.class) + .subAgents(mathsAgent) + .outputKey("result") + .build(); + + SpringAgentServerService.buildAgent(agent); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/test/java/SpringAgentServerService.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/test/java/SpringAgentServerService.java new file mode 100644 index 000000000000..cc14fae57d01 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample/src/test/java/SpringAgentServerService.java @@ -0,0 +1,16 @@ +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.api.langchain4j.Langchain4jResponsesHandler; +import com.microsoft.agentserver.server.spring.SpringAgentServerAdaptorService; +import dev.langchain4j.agentic.UntypedAgent; +import org.springframework.context.ConfigurableApplicationContext; + +public class SpringAgentServerService { + + public static ConfigurableApplicationContext buildAgent(UntypedAgent agent) { + Langchain4jResponsesHandler handler = new Langchain4jResponsesHandler(agent, null); + ResponsesApi res = ResponsesApi.create(handler); + + return SpringAgentServerAdaptorService.run("8080", res); + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/Dockerfile b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/Dockerfile new file mode 100644 index 000000000000..c748771abecd --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/Dockerfile @@ -0,0 +1,15 @@ +FROM eclipse-temurin:25-jdk + +WORKDIR /app + +# Download the Application Insights Java agent +ADD https://github.com/microsoft/ApplicationInsights-Java/releases/download/3.7.8/applicationinsights-agent-3.7.8.jar applicationinsights-agent.jar + +COPY target/azure-agentserver-langchain4j-financial-jersey-sample-*-jar-with-dependencies.jar app.jar +COPY applicationinsights.json applicationinsights.json +COPY scripts/run.sh run.sh + +EXPOSE 8088 + +ENTRYPOINT ["/app/run.sh"] + diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/applicationinsights.json b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/applicationinsights.json new file mode 100644 index 000000000000..151ce1e838be --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/applicationinsights.json @@ -0,0 +1,14 @@ +{ + "role": { + "name": "financial-jersey-agent" + }, + "sampling": { + "percentage": 100 + }, + "instrumentation": { + "logging": { + "level": "DEBUG" + } + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/docker-compose.yml b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/docker-compose.yml new file mode 100644 index 000000000000..7dec2698916d --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/docker-compose.yml @@ -0,0 +1,15 @@ +services: + financial-jersey-sample: + build: + context: . + dockerfile: Dockerfile + image: ${REGISTRY:-}lc4jsample:latest + + ports: + - "8088:8088" + - "5005:5005" + env_file: + - ../../../.env + + + diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/pom.xml b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/pom.xml new file mode 100644 index 000000000000..2536fca6e7f1 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/pom.xml @@ -0,0 +1,110 @@ + + 4.0.0 + + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../../pom.xml + + + azure-agentserver-langchain4j-financial-jersey-sample + jar + azure-agentserver-langchain4j-financial-jersey-sample + + Financial agent sample using LangChain4j and Jersey with the Azure AI Foundry Agent Server Server + + + + com.microsoft.agentserver + azure-agentserver-jersey + + + org.glassfish.jersey.containers + jersey-container-grizzly2-http + + + org.glassfish.jersey.inject + jersey-hk2 + + + org.glassfish.jersey.media + jersey-media-json-jackson + + + org.glassfish.jersey.media + jersey-media-sse + + + org.glassfish.jersey.core + jersey-common + + + + com.microsoft.agentserver + azure-agentserver-langchain4j-financial-sample + + + io.opentelemetry + opentelemetry-api + + + io.netty + netty-resolver-dns + + + com.azure + azure-identity + + + + + org.apache.logging.log4j + log4j-api + + + org.apache.logging.log4j + log4j-core + + + org.slf4j + slf4j-log4j12 + + + org.apache.logging.log4j + log4j-jul + runtime + + + + + src/main/java + + + org.apache.maven.plugins + maven-assembly-plugin + ${maven-assembly-plugin.version} + + + + com.microsoft.agentserver.sample.financial.jersey.Main + + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/run-sample.sh b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/run-sample.sh new file mode 100755 index 000000000000..f14c2bbb4925 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/run-sample.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +../../../mvnw package -DskipTests + +# Load environment variables from .env +set -a +source ../../../.env +set +a + +export REGISTRY=${REGISTRY:-} + +docker-compose build --no-cache +docker-compose up --force-recreate -d + +# wait for the container to be ready +sleep 20 + +curl -vvv -N -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Transfer 100 from Alice to Bob?", + "model": "gpt-4o", + "stream": false + }' + +curl -vvv -N -X POST http://localhost:8088/responses \ + -H "Accept: text/event-stream" \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Transfer 100 from Alice to Bob?", + "model": "gpt-4o", + "stream": true + }' + +docker compose down + diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/scripts/run.sh b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/scripts/run.sh new file mode 100755 index 000000000000..d72750157f51 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/scripts/run.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# Note: the ADC egress proxy CA certificate is installed into the JVM truststore +# at startup from Java code (see TrustStoreInstaller.installAdcEgressProxyCertificate). + +export CA_LOG_REQUESTS=true + +java -Dlog4j.provider=org.apache.logging.log4j.simple.internal.SimpleProvider -javaagent:/app/applicationinsights-agent.jar -jar app.jar diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/java/com/microsoft/agentserver/sample/financial/jersey/Main.java b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/java/com/microsoft/agentserver/sample/financial/jersey/Main.java new file mode 100644 index 000000000000..ac3f54917ef5 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/java/com/microsoft/agentserver/sample/financial/jersey/Main.java @@ -0,0 +1,79 @@ +package com.microsoft.agentserver.sample.financial.jersey; + +import com.azure.core.credential.TokenCredential; +import com.microsoft.agentserver.api.FoundryEnvironment; +import com.microsoft.agentserver.api.TrustStoreInstaller; +import com.microsoft.agentserver.api.langchain4j.Langchain4jResponsesHandler; +import com.microsoft.agentserver.api.langchain4j.SupervisorAgentWithMemory; +import com.microsoft.agentserver.sample.financial.AccountingAgent; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; +import dev.langchain4j.model.azure.AzureOpenAiChatModel; +import org.apache.log4j.BasicConfigurator; +import org.slf4j.Logger; + +public class Main { + + private static final Logger LOGGER; + + static { + if (System.getProperty("java.util.logging.manager") == null) { + System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); + } + + BasicConfigurator.configure(); + + LOGGER = org.slf4j.LoggerFactory.getLogger(Main.class); + } + + public static void main(String[] args) throws InterruptedException { + try { + TrustStoreInstaller.installAdcEgressProxyCertificate(LOGGER); + } catch (Exception e) { + LOGGER.warn("Failed to install ADC egress proxy CA certificate into the JVM truststore.", e); + } + + String openAiEndpoint = FoundryEnvironment.OPENAI_ENDPOINT; + if (openAiEndpoint == null || openAiEndpoint.isBlank()) { + LOGGER.error("No endpoint configured. Set FOUNDRY_PROJECT_ENDPOINT or AZURE_OPENAI_ENDPOINT."); + System.exit(1); + } + + LOGGER.info("=== Resolved Configuration ==="); + LOGGER.info(" Agent Name: {}", FoundryEnvironment.AGENT_NAME); + LOGGER.info(" Model Deployment: {}", FoundryEnvironment.MODEL_DEPLOYMENT_NAME); + LOGGER.info(" Project Endpoint: {}", FoundryEnvironment.PROJECT_ENDPOINT); + LOGGER.info(" OpenAI Endpoint: {}", openAiEndpoint); + LOGGER.info(" Hosted: {}", FoundryEnvironment.IS_HOSTED); + LOGGER.info("=== End Configuration ==="); + + AzureOpenAiChatModel model = getAzureOpenAiChatModel(openAiEndpoint); + + SupervisorAgentWithMemory agent = AccountingAgent.build(model); + + JerseyAgentServerAdaptorService.buildAgent( + Langchain4jResponsesHandler.builder() + .supervisorAgent(agent) + .build()); + + Thread.currentThread().join(); + } + + + private static AzureOpenAiChatModel getAzureOpenAiChatModel(String openAiEndpoint) { + AzureOpenAiChatModel.Builder modelBuilder = AzureOpenAiChatModel.builder() + .deploymentName(FoundryEnvironment.MODEL_DEPLOYMENT_NAME); + + String nonAzureApiKey = System.getenv("AZURE_CLIENT_KEY"); + if (nonAzureApiKey != null && !nonAzureApiKey.isBlank()) { + LOGGER.info("Using non-Azure API key for authentication"); + modelBuilder.nonAzureApiKey(nonAzureApiKey); + } else { + TokenCredential credential = FoundryEnvironment.resolveCredential(); + modelBuilder.tokenCredential(credential); + } + + return modelBuilder + .endpoint(openAiEndpoint) + .build(); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/java/com/microsoft/agentserver/sample/financial/jersey/OpenTelemetryRequestFilter.java b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/java/com/microsoft/agentserver/sample/financial/jersey/OpenTelemetryRequestFilter.java new file mode 100644 index 000000000000..871e5a9112ae --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/java/com/microsoft/agentserver/sample/financial/jersey/OpenTelemetryRequestFilter.java @@ -0,0 +1,79 @@ +package com.microsoft.agentserver.sample.financial.jersey; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import org.slf4j.Logger; + +/** + * Jersey filter that creates OpenTelemetry SERVER spans for every HTTP request. + * When the Application Insights Java agent is attached, these spans are exported + * as request telemetry. Without the agent, they are harmless no-ops. + */ +public class OpenTelemetryRequestFilter implements ContainerRequestFilter, ContainerResponseFilter { + private static final Logger LOGGER = org.slf4j.LoggerFactory.getLogger(OpenTelemetryRequestFilter.class); + + private static final String SPAN_PROPERTY = "otel.span"; + private static final String SCOPE_PROPERTY = "otel.scope"; + + // Resolved configuration — set by Main before the server starts + public static String agentName = "unknown"; + public static String modelDeploymentName = "unknown"; + public static String projectEndpoint = "unknown"; + public static String openAiEndpoint = "unknown"; + public static String identityEndpoint = "not set"; + + private final Tracer tracer = GlobalOpenTelemetry.getTracer("agent-server"); + + @Override + public void filter(ContainerRequestContext request) { + String method = request.getMethod(); + String path = "/" + request.getUriInfo().getPath(); + + LOGGER.info("Incoming request: {} {} | Agent={}, Model={}, ProjectEndpoint={}, OpenAIEndpoint={}, IdentityEndpoint={}", + method, path, agentName, modelDeploymentName, projectEndpoint, openAiEndpoint, identityEndpoint); + + Span span = tracer.spanBuilder(method + " " + path) + .setSpanKind(SpanKind.SERVER) + .setAttribute("http.request.method", method) + .setAttribute("url.path", path) + .startSpan(); + + Scope scope = span.makeCurrent(); + + request.setProperty(SPAN_PROPERTY, span); + request.setProperty(SCOPE_PROPERTY, scope); + } + + @Override + public void filter(ContainerRequestContext request, ContainerResponseContext response) { + try { + //String body = new String(request.getEntityStream().readAllBytes()); + LOGGER.debug("Incoming response: {}", request.getUriInfo().getPath()); + } catch (Exception e) { + LOGGER.error("Error reading request entity", e); + } + Scope scope = (Scope) request.getProperty(SCOPE_PROPERTY); + Span span = (Span) request.getProperty(SPAN_PROPERTY); + + if (span != null) { + span.setAttribute("http.response.status_code", response.getStatus()); + if (response.getStatus() >= 400) { + span.setStatus(StatusCode.ERROR); + } + span.end(); + } + + if (scope != null) { + scope.close(); + } + } +} + diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/resources/log4j2.xml b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/resources/log4j2.xml new file mode 100644 index 000000000000..a7bbaddd2206 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/resources/log4j2.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/pom.xml b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/pom.xml new file mode 100644 index 000000000000..28c9665ad5d1 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/pom.xml @@ -0,0 +1,78 @@ + + 4.0.0 + + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../../pom.xml + + + azure-agentserver-langchain4j-financial-sample + jar + azure-agentserver-langchain4j-financial-sample + + Financial agent sample using LangChain4j with the Azure AI Foundry Agent Server Server + + + + com.microsoft.agentserver + azure-agentserver-langchain4j + + + dev.langchain4j + langchain4j-azure-open-ai + + + com.azure + azure-core-http-netty + + + + + com.azure + azure-core-http-netty + + + io.netty + * + + + + + + + io.netty + netty-handler + + + io.netty + netty-handler-proxy + + + io.netty + netty-buffer + + + io.netty + netty-codec + + + io.netty + netty-codec-http + + + io.netty + netty-codec-http2 + + + io.netty + netty-common + + + + + src/main/java + + diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/AccountInfoAgent.java b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/AccountInfoAgent.java new file mode 100644 index 000000000000..b146a7f49337 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/AccountInfoAgent.java @@ -0,0 +1,18 @@ +package com.microsoft.agentserver.sample.financial; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +public interface AccountInfoAgent { + + @SystemMessage(""" + You are a banker that provides information about a users account, + """) + @UserMessage(""" + Get {{user}}'s account balance. + """) + @Agent("A banker that provides information about a users account") + Double getBalance(@V("user") String user); +} diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/AccountingAgent.java b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/AccountingAgent.java new file mode 100644 index 000000000000..f8c8486f902c --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/AccountingAgent.java @@ -0,0 +1,57 @@ +package com.microsoft.agentserver.sample.financial; + +import com.microsoft.agentserver.api.langchain4j.SupervisorAgentWithMemory; +import dev.langchain4j.agentic.AgenticServices; +import dev.langchain4j.agentic.supervisor.SupervisorContextStrategy; +import dev.langchain4j.agentic.supervisor.SupervisorResponseStrategy; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.chat.ChatModel; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class AccountingAgent { + + public static SupervisorAgentWithMemory build(ChatModel model) { + BankTool bankTool = new BankTool(); + bankTool.createAccount("Mario", 1000.0); + bankTool.createAccount("Georgio", 1000.0); + bankTool.createAccount("Alice", 1000.0); + bankTool.createAccount("Bob", 1000.0); + + WithdrawAgent withdrawAgent = AgenticServices + .agentBuilder(WithdrawAgent.class) + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10)) + .chatModel(model) + .tools(bankTool) + .build(); + + CreditAgent creditAgent = AgenticServices + .agentBuilder(CreditAgent.class) + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10)) + .chatModel(model) + .tools(bankTool) + .build(); + + ExchangeAgent exchange = AgenticServices + .agentBuilder(ExchangeAgent.class) + .chatModel(model) + .tools(new ExchangeTool()) + .build(); + + AccountInfoAgent accountInfoAgent = AgenticServices + .agentBuilder(AccountInfoAgent.class) + .chatModel(model) + .tools(bankTool) + .build(); + + return AgenticServices + .supervisorBuilder(SupervisorAgentWithMemory.class) + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) + .chatModel(model) + .subAgents(withdrawAgent, creditAgent, exchange, accountInfoAgent) + .responseStrategy(SupervisorResponseStrategy.SCORED) + .contextGenerationStrategy(SupervisorContextStrategy.CHAT_MEMORY) + .build(); + } + +} diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/BankTool.java b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/BankTool.java new file mode 100644 index 000000000000..ac4a9468d2c0 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/BankTool.java @@ -0,0 +1,50 @@ +package com.microsoft.agentserver.sample.financial; + +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; + +import java.util.HashMap; +import java.util.Map; + +public class BankTool { + + private final Map accounts = new HashMap<>(); + + public void createAccount(String user, Double initialBalance) { + if (accounts.containsKey(user)) { + throw new RuntimeException("Account for user " + user + " already exists"); + } + accounts.put(user, initialBalance); + } + + @Tool("Get the balance of the given user") + public double getBalance(@P("user name") String user) { + Double balance = accounts.get(user); + if (balance == null) { + throw new RuntimeException("No balance found for user " + user); + } + return balance; + } + + @Tool("Credit the given user with the given amount and return the new balance") + public Double credit(@P("user name") String user, @P("amount") Double amount) { + Double balance = accounts.get(user); + if (balance == null) { + throw new RuntimeException("No balance found for user " + user); + } + Double newBalance = balance + amount; + accounts.put(user, newBalance); + return newBalance; + } + + @Tool("Withdraw the given amount with the given user and return the new balance") + public Double withdraw(@P("user name") String user, @P("amount") Double amount) { + Double balance = accounts.get(user); + if (balance == null) { + throw new RuntimeException("No balance found for user " + user); + } + Double newBalance = balance - amount; + accounts.put(user, newBalance); + return newBalance; + } +} \ No newline at end of file diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/CreditAgent.java b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/CreditAgent.java new file mode 100644 index 000000000000..c871cc6ca467 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/CreditAgent.java @@ -0,0 +1,17 @@ +package com.microsoft.agentserver.sample.financial; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +public interface CreditAgent { + @SystemMessage(""" + You are a banker that can only credit US dollars (USD) to a user account, + """) + @UserMessage(""" + Credit {{amount}} USD to {{user}}'s account and return the new balance. + """) + @Agent("A banker that credit USD to an account") + String credit(@V("user") String user, @V("amount") Double amount); +} diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/ExchangeAgent.java b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/ExchangeAgent.java new file mode 100644 index 000000000000..4b2bf35aa476 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/ExchangeAgent.java @@ -0,0 +1,15 @@ +package com.microsoft.agentserver.sample.financial; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +public interface ExchangeAgent { + @UserMessage(""" + You are an operator exchanging money in different currencies. + Use the tool to exchange {{amount}} {{originalCurrency}} into {{targetCurrency}} + returning only the final amount provided by the tool as it is and nothing else. + """) + @Agent("A money exchanger that converts a given amount of money from the original to the target currency") + Double exchange(@V("originalCurrency") String originalCurrency, @V("amount") Double amount, @V("targetCurrency") String targetCurrency); +} diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/ExchangeTool.java b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/ExchangeTool.java new file mode 100644 index 000000000000..d71a0c59e907 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/ExchangeTool.java @@ -0,0 +1,33 @@ +package com.microsoft.agentserver.sample.financial; + +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; + +import java.util.Locale; +import java.util.Map; + +public class ExchangeTool { + private static final Map exchangeRates = Map.of( + "USD_EUR", 0.9, + "USD_GBP", 0.8, + "EUR_USD", 1.1, + "EUR_GBP", 0.88, + "GBP_USD", 1.25, + "GBP_EUR", 1.14 + ); + + @Tool("Exchange the given amount of money from the original to the target currency") + Double exchange(@P("originalCurrency") String originalCurrency, @P("amount") Double amount, @P("targetCurrency") String targetCurrency) { + if (originalCurrency.toUpperCase(Locale.ROOT).equals(targetCurrency.toUpperCase(Locale.ROOT))) { + return amount; + } + + String key = originalCurrency.toUpperCase(Locale.ROOT) + "_" + targetCurrency.toUpperCase(Locale.ROOT); + + if (!exchangeRates.containsKey(key)) { + throw new RuntimeException("No exchange rate found for " + key); + } + + return amount * exchangeRates.get(key); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/WithdrawAgent.java b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/WithdrawAgent.java new file mode 100644 index 000000000000..230b0be7ffeb --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/java/com/microsoft/agentserver/sample/financial/WithdrawAgent.java @@ -0,0 +1,19 @@ +package com.microsoft.agentserver.sample.financial; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +public interface WithdrawAgent { + + @SystemMessage(""" + You are a banker that can only withdraw US dollars (USD) from a user account, + """) + @UserMessage(""" + Withdraw {{amount}} USD from {{user}}'s account and return the new balance. + """) + @Agent("A banker that withdraws USD from an account") + String withdraw(@V("user") String user, @V("amount") Double amount); +} + diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/resources/META-INF/beans.xml b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/resources/META-INF/beans.xml new file mode 100644 index 000000000000..0eae218a41d2 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample/src/main/resources/META-INF/beans.xml @@ -0,0 +1,7 @@ + + + diff --git a/sdk/agentserver/docs/developer-guide.md b/sdk/agentserver/docs/developer-guide.md new file mode 100644 index 000000000000..d3fbf91a4789 --- /dev/null +++ b/sdk/agentserver/docs/developer-guide.md @@ -0,0 +1,127 @@ +# Azure Agent Server - Developer Guide + +This guide describes how `sdk/agentserver` is structured, what `azure-agentserver-api` provides, and how consumers wire +it into runnable services. + +## Architecture overview + +`sdk/agentserver` is a multi-module Maven project with three layers: + +1. **Core API (`azure-agentserver-api`)** + - Protocol-facing types (`AgentServerCreateResponse`, `CreateResponse`, event streams). + - Extension points (`ResponseHandler`, `ResponsesProvider`). + - Default orchestration (`AgentServerResponsesApi`) for sync + streaming + background flows. +2. **Web adapters** + - Spring (`azure-agentserver-spring`) + - JAX-RS/Jersey (`azure-agentserver-api-jaxrs`, `azure-agentserver-jersey`) +3. **Samples and example framework integrations** + - Runnable end-user samples under `azure-agentserver-samples`. + - Example framework integrations under: + `azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks`. + +## Core runtime flow + +1. HTTP adapter receives request and builds `RequestMetadata` (headers, isolation, client headers, query params). +2. Adapter delegates to `ResponsesApi`. +3. `AgentServerResponsesApi` resolves response/session IDs, builds `ResponseContext`, and invokes `ResponseHandler`. +4. Results are normalized/persisted and returned as JSON or SSE. +5. Platform headers are emitted (`x-request-id`, `x-platform-server`, optional `x-agent-session-id`). + +## Key extension points + +- **Implement a custom agent:** implement `ResponseHandler`. + - `createResponse(...)` for sync responses. + - `createAsync(...)` for SSE responses. +- **Customize persistence:** implement `ResponsesProvider`. +- **Swap web stack:** use either Spring adapter or Jersey adapter around the same `ResponsesApi`. + +## `azure-agentserver-api` deep dive + +`azure-agentserver-api` is the core contract layer your code should target. + +### Main responsibilities + +- Defines protocol contracts (`ResponsesApi`, `ResponseHandler`, `ResponseEventStream`, `ResponseContext`). +- Provides default orchestration (`AgentServerResponsesApi`) for: + - sync and streaming response creation + - response persistence + - background lifecycle and cancellation + - stream replay for stored streaming responses +- Handles platform-aware request metadata: + - isolation context + - client headers (`x-client-*`) + - request IDs + - session IDs (`agent_session_id`) +- Provides ID/session normalization helpers (`IdGenerator`, `SessionIdResolver`, `ResponseBuilder`). + +### How handlers are expected to use it + +Inside a `ResponseHandler`, `ResponseContext` is the primary runtime surface: + +- `getResponseId()` for correlation and deterministic child IDs. +- `getInputItemsAsync()` for normalized request input (including resolved references). +- `getHistoryAsync()` for conversation history stitching. +- `isCancelled()`/`isShutdownRequested()` for cooperative cancellation. +- `getIsolation()`, `getClientHeaders()`, `getQueryParameters()`, `getRequestId()`, `getSessionId()` for platform + context. + +### Minimal integration pattern + +```java +ResponsesApi api = ResponsesApi.builder() + .responseHandler(new MyHandler()) + .build(); +``` + +Then host `api` with either adapter: + +- Jersey: `JerseyAgentServerAdaptorService.buildAgent(api);` +- Spring: `SpringAgentServerAdaptorService.run(api);` + +### Streaming model + +- Handler returns `ResponseEventStream` from `createAsync(...)`. +- Emit lifecycle and output events via fluent builders (`emitCreated()`, `emitInProgress()`, `addOutputMessage(...)`, + `emitCompleted()`). +- Adapters translate stream events into SSE frames on `/responses/streaming`. +- Stored background streams can be replayed via `GET /responses/{id}/stream`. + +### Persistence model + +- If hosted environment variables are present, provider auto-resolves to `FoundryStorageProvider`. +- Otherwise defaults to `InMemoryResponseProvider`. +- You can override with a custom `ResponsesProvider` via `ResponsesApi.builder().provider(...)`. + +## Streaming and background behavior + +- Streaming endpoint is `/responses/streaming`. +- Replay endpoint is `GET /responses/{id}/stream` and requires a stored streaming response. +- `background=true` requires `store=true`. +- Cancellation is supported through `/responses/{id}/cancel`. +- Client disconnect signaling is coordinated so cancelled state wins over late terminal writes. + +## Module map + +| Module | What to change here | +|----------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------| +| `azure-agentserver-api` | Protocol/domain behavior, IDs, storage logic, event stream semantics. | +| `azure-agentserver-spring` | Spring filters/controllers/exception mapping and bootstrapping. | +| `azure-agentserver-api-jaxrs` | JAX-RS resources/filters/exception mapping. | +| `azure-agentserver-jersey` | Jersey runtime and server host wiring. | +| `azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j` | Example LangChain4j request/message mapping and invocation plumbing. | +| `azure-agentserver-samples` | Reference implementations and runnable usage patterns. | + +## Build and test + +From `sdk/agentserver`: + +```bash +./mvnw clean package -DskipTests +./mvnw clean verify +``` + +## Important support boundary + +`azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks` is +intentionally shipped as **integration sample/example code** and is **not an officially supported API surface**. Keep +framework-specific behavior isolated there and avoid coupling core API contracts to framework internals. diff --git a/sdk/agentserver/docs/end-user-guide.md b/sdk/agentserver/docs/end-user-guide.md new file mode 100644 index 000000000000..8f4e851557d4 --- /dev/null +++ b/sdk/agentserver/docs/end-user-guide.md @@ -0,0 +1,143 @@ +# Azure Agent Server - End-User Guide + +This guide shows how to run and use the Java agent server as a library consumer. + +## What you get + +- A server adapter that implements OpenAI Responses-style endpoints. +- Health endpoints for container readiness/liveness probes. +- Sample apps for echo, LangChain4j, Spring, and financial multi-agent scenarios. + +## Quick start with samples + +All sample projects are under `azure-agentserver-samples`. + +### Echo sample + +```bash +cd azure-agentserver-samples/azure-agentserver-echo-sample +./run-sample.sh +``` + +### LangChain4j sample (Jersey) + +1. For **local testing only**, set env vars in `sdk/agentserver/.env`: + - `AZURE_DEPLOYMENT_NAME` + - `AZURE_ENDPOINT` + - `AZURE_API_KEY` +2. Run: + +```bash +cd azure-agentserver-samples/azure-agentserver-langchain4j-sample +./run-sample.sh +``` + +### LangChain4j sample (Spring) + +```bash +cd azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample +./run-sample.sh +``` + +> [!IMPORTANT] +> `AZURE_API_KEY` is intended for local/dev sample execution. In Azure AI Foundry hosted deployments, you typically +> should **not** require end users to set API keys and should use **Managed Identity** (for example via +> `DefaultAzureCredential`) for authentication. + +## Foundry hosted deployment configuration + +For Azure AI Foundry hosted deployments, users typically do **not** need to define: + +- `AZURE_API_KEY` +- `AZURE_ENDPOINT` +- `AZURE_DEPLOYMENT_NAME` + +The expected production pattern is Managed Identity authentication. + +### Typically required (platform-provided) + +- `FOUNDRY_HOSTING_ENVIRONMENT` +- `FOUNDRY_PROJECT_ENDPOINT` + +### Common platform context variables + +- `FOUNDRY_AGENT_NAME` +- `FOUNDRY_AGENT_VERSION` +- `FOUNDRY_AGENT_SESSION_ID` + +### Optional (scenario-dependent) + +- `MODEL_DEPLOYMENT_NAME` (override default model deployment name) +- `AZURE_CLIENT_ID` (when using a user-assigned managed identity) + +## Calling the service + +### Non-streaming request + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Echo this back to me.", + "model": "gpt-4o" + }' +``` + +### Streaming request (SSE) + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Accept: text/event-stream" \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Echo this back to me.", + "model": "gpt-4o", + "stream": true + }' --no-buffer +``` + +## Using `azure-agentserver-api` as a library + +Typical bootstrap patterns from samples: + +- **Jersey runtime** + - Build `ResponsesApi` with your `ResponseHandler`. + - Start with `JerseyAgentServerAdaptorService.buildAgent(...)`. +- **Spring runtime** + - Build a handler (for example LangChain4j-backed). + - Start with `SpringAgentServerAdaptorService.run(...)`. + +Minimal pattern: + +```java +ResponsesApi api = ResponsesApi.builder() + .responseHandler(new MyHandler()) + .build(); +``` + +Your handler can support: + +- `createResponse(...)` for non-streaming requests. +- `createAsync(...)` for streaming requests using `ResponseEventStream`. + +In handler code, use `ResponseContext` to access: + +- resolved input items (`getInputItemsAsync`) +- conversation history (`getHistoryAsync`) +- cancellation signals (`isCancelled`) +- platform metadata (`getIsolation`, `getRequestId`, `getSessionId`) + +## Notes for consumers + +- Default port in provided adapters/samples is `8088`. +- `CA_LOG_REQUESTS=true` enables request/response logging in adapters. +- Doc-focused examples are in `azure-agentserver-doc-samples` (sample1..sample10 patterns). +- Example framework integrations are in + `azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks`. + +## Framework integration support boundary + +`azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks` (for +example LangChain4j integration) is provided as **sample/example integration code** and is **not an officially supported +API contract**. Build production code against the core API (`azure-agentserver-api`) and web adapters. diff --git a/sdk/agentserver/mvnw b/sdk/agentserver/mvnw new file mode 100755 index 000000000000..5643201c7d82 --- /dev/null +++ b/sdk/agentserver/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/sdk/agentserver/pom.xml b/sdk/agentserver/pom.xml new file mode 100644 index 000000000000..29422258ad9c --- /dev/null +++ b/sdk/agentserver/pom.xml @@ -0,0 +1,335 @@ + + 4.0.0 + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + + azure-agentserver-parent + Parent POM for the Azure AI Foundry Agent Server Server SDK + + pom + + + azure-agentserver-api + + azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-spring + azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-api-jaxrs + azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-webframeworks/azure-agentserver-jaxrs/azure-agentserver-jersey + + azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j-bom + azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-langchain4j + azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests + + azure-agentserver-samples/azure-agentserver-echo-sample + + azure-agentserver-samples/azure-agentserver-langchain4j-sample + azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample + + azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample + azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample + + azure-agentserver-samples/azure-agentserver-doc-samples + + + + 17 + ${java.version} + ${java.version} + UTF-8 + + 4.0.1 + 2.21.0 + 2.0.16 + 4.32.0 + 1.10.0 + 1.10.0-beta18 + 4.2.15.Final + 1.58.0 + 1.16.2 + 1.18.3 + 5.10.2 + 1.40.0 + 3.4.5 + 3.31.1 + 2.25.4 + 5.18.0 + 3.13.0 + 3.7.1 + 1.6.2 + + + + + + org.slf4j + slf4j-bom + ${slf4j.version} + pom + import + + + io.netty + netty-bom + ${netty.version} + pom + import + + + io.opentelemetry + opentelemetry-bom + ${opentelemetry.version} + pom + import + + + + + com.microsoft.agentserver + azure-agentserver-api + ${project.version} + + + com.microsoft.agentserver + azure-agentserver-api-jaxrs + ${project.version} + + + com.microsoft.agentserver + azure-agentserver-jersey + ${project.version} + + + com.microsoft.agentserver + azure-agentserver-spring + ${project.version} + + + com.microsoft.agentserver + azure-agentserver-langchain4j-bom + ${project.version} + + + com.microsoft.agentserver + azure-agentserver-langchain4j-bom + ${project.version} + pom + + + com.microsoft.agentserver + azure-agentserver-langchain4j + ${project.version} + + + com.microsoft.agentserver + azure-agentserver-langchain4j-financial-sample + ${project.version} + + + + + com.openai + openai-java-core + ${openai.version} + + + com.fasterxml.jackson.core + * + + + com.fasterxml.jackson.datatype + * + + + + + com.openai + openai-java + ${openai.version} + + + com.openai + openai-java-client-okhttp + ${openai.version} + + + + + dev.langchain4j + langchain4j + ${langchain4j.version} + + + dev.langchain4j + langchain4j-open-ai + ${langchain4j.version} + + + dev.langchain4j + langchain4j-azure-open-ai + ${langchain4j.version} + + + com.azure + azure-core-http-netty + + + + + dev.langchain4j + langchain4j-agentic + ${langchain4j.agentic.version} + + + + + com.azure + azure-core + ${azure.core.version} + + + com.azure + azure-core-http-netty + ${azure.core.http.netty.version} + + + io.netty + * + + + + + com.azure + azure-identity + ${azure.identity.version} + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-annotations + 2.21 + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + ${jackson.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + + + com.fasterxml.jackson.module + jackson-module-kotlin + ${jackson.version} + + + + + org.glassfish.jersey.containers + jersey-container-grizzly2-http + ${jersey.version} + + + org.glassfish.jersey.inject + jersey-hk2 + ${jersey.version} + + + org.glassfish.jersey.media + jersey-media-json-jackson + ${jersey.version} + + + org.glassfish.jersey.media + jersey-media-sse + ${jersey.version} + + + org.glassfish.jersey.core + jersey-common + ${jersey.version} + + + org.glassfish.jersey.test-framework.providers + jersey-test-framework-provider-grizzly2 + ${jersey.version} + + + + + jakarta.enterprise + jakarta.enterprise.cdi-api + 4.1.0 + + + jakarta.ws.rs + jakarta.ws.rs-api + 4.0.0 + + + io.smallrye.reactive + mutiny + 3.1.0 + + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.slf4j + slf4j-log4j12 + 2.0.17 + + + org.apache.logging.log4j + log4j-api + ${log4j.version} + + + org.apache.logging.log4j + log4j-core + ${log4j.version} + + + org.apache.logging.log4j + log4j-slf4j2-impl + ${log4j.version} + + + org.apache.logging.log4j + log4j-jul + ${log4j.version} + + + + + org.junit.jupiter + junit-jupiter + ${junit.jupiter.version} + + + org.mockito + mockito-core + ${mockito.version} + + + org.wiremock + wiremock-standalone + ${wiremock.version} + + + + + From ee014c1f7fa8710ab281669226295f055cd9216e Mon Sep 17 00:00:00 2001 From: John Oliver <1615532+johnoliver@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:20:13 +0000 Subject: [PATCH 2/4] Add module to parent pom --- pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/pom.xml b/pom.xml index 571455b1f3e5..36d92291f151 100644 --- a/pom.xml +++ b/pom.xml @@ -12,6 +12,7 @@ common/perf-test-core sdk/advisor sdk/agrifood + sdk/agentserver sdk/ai sdk/alertsmanagement sdk/anomalydetector From 893e46212e2f9ad50cdcaea5a636e44ad8c8d68c Mon Sep 17 00:00:00 2001 From: John Oliver <1615532+johnoliver@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:30:17 +0000 Subject: [PATCH 3/4] Fix cspell issues in agentserver --- .vscode/cspell.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index acd03fb12e20..434833b17c89 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -475,6 +475,17 @@ "xmlparserv" ], "overrides": [ + { + "filename": "sdk/agentserver/**", + "words": [ + "changeit", + "dotenv", + "jaxrs", + "noprompt", + "PKIX", + "trustcacerts" + ] + }, { "filename": "sdk/planetarycomputer/**", "words": [ From 7eeade1edb2840a647d0b3d9b22d5612cf7a44a1 Mon Sep 17 00:00:00 2001 From: John Oliver <1615532+johnoliver@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:47:36 +0000 Subject: [PATCH 4/4] Add agentserver samples: Italian translator and azure-ai-agents SDK client Add two new Agent Server samples and supporting API changes: - azure-agentserver-translator-sample: an Agent Server that translates input text to Italian via Azure OpenAI, demonstrating both the synchronous completions path and a token-by-token streaming path. - azure-ai-agents-sdk-client-sample: end-to-end client sample that deploys a hosted agent to Azure AI Foundry and invokes it through the com.azure:azure-ai-agents SDK, with idempotent deployment and printed managed-identity RBAC guidance. azure-agentserver-api changes supporting the translator sample: - AgentServerCreateResponse.inputText() now handles the structured input[] message shape (Foundry chat playground / multi-turn), not just a plain text input, returning the most recent user message. - ResponseEventStream gains streamChatCompletion / streamDeltas helpers that forward chat-completion chunks as output_text delta events. - Added serialization tests for the new inputText shapes. Register both modules in the agentserver parent pom. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../api/AgentServerCreateResponse.java | 91 ++++- .../api/AgentServerResponseEventStream.java | 25 ++ .../agentserver/api/ResponseEventStream.java | 43 +++ .../api/SerializationIntegrationTest.java | 49 +++ .../Dockerfile | 13 + .../applicationinsights.json | 13 + .../docker-compose.yml | 14 + .../pom.xml | 77 +++++ .../run-sample.sh | 34 ++ .../agentserver/ItalianTranslatorHandler.java | 121 +++++++ .../java/com/microsoft/agentserver/Main.java | 83 +++++ .../.env.template | 49 +++ .../README.md | 122 +++++++ .../azure-ai-agents-sdk-client-sample/pom.xml | 111 ++++++ .../run-sample.sh | 96 ++++++ .../sample/financial/sdkclient/Main.java | 324 ++++++++++++++++++ .../sample/financial/jersey/Main.java | 1 - sdk/agentserver/pom.xml | 2 + 18 files changed, 1262 insertions(+), 6 deletions(-) create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/Dockerfile create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/applicationinsights.json create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/docker-compose.yml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/pom.xml create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/run-sample.sh create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/src/main/java/com/microsoft/agentserver/ItalianTranslatorHandler.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/src/main/java/com/microsoft/agentserver/Main.java create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/.env.template create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/README.md create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/pom.xml create mode 100755 sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/run-sample.sh create mode 100644 sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/src/main/java/com/microsoft/agentserver/sample/financial/sdkclient/Main.java diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerCreateResponse.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerCreateResponse.java index a9b12f3e8515..99992217c3bb 100644 --- a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerCreateResponse.java +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerCreateResponse.java @@ -11,13 +11,16 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.openai.models.responses.EasyInputMessage; import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseInputContent; import com.openai.models.responses.ResponseInputItem; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.List; +import java.util.Optional; /** * Represents an incoming create-response request in the agent server protocol. @@ -33,7 +36,15 @@ public record AgentServerCreateResponse(AgentReference agent, ResponseCreatePara /** * Extracts the input text from the request. - * If the input is a simple text string, returns it directly. + *

+ * Handles both request shapes accepted by the Responses API: + *

    + *
  • A simple text input ({@code "input": "hello"}) — returned directly.
  • + *
  • A structured input array of message items (the shape sent by the Foundry + * chat playground and multi-turn clients) — the text of the most recent + * {@code user} message is returned. If no {@code user} message is found, + * the text of the last message containing any text is used.
  • + *
* Returns an empty string if no text input is present. * * @return the input text, or empty string @@ -42,10 +53,80 @@ public String inputText() { if (responseCreateParams == null) { return ""; } - return responseCreateParams.input() - .filter(ResponseCreateParams.Input::isText) - .map(ResponseCreateParams.Input::asText) - .orElse(""); + + Optional inputOpt = responseCreateParams.input(); + if (inputOpt.isEmpty()) { + return ""; + } + + ResponseCreateParams.Input input = inputOpt.get(); + if (input.isText()) { + return input.asText(); + } + + if (input.isResponse()) { + return extractTextFromItems(input.asResponse()); + } + + return ""; + } + + /** + * Walks the input items and returns the text of the most recent {@code user} + * message, falling back to the last message that contains any text. + */ + private static String extractTextFromItems(List items) { + String lastUserText = ""; + String lastAnyText = ""; + + for (ResponseInputItem item : items) { + String text; + boolean isUser; + + if (item.isEasyInputMessage()) { + EasyInputMessage message = item.asEasyInputMessage(); + text = textFromEasyContent(message.content()); + isUser = message.role().equals(EasyInputMessage.Role.USER); + } else if (item.isMessage()) { + ResponseInputItem.Message message = item.asMessage(); + text = textFromContentList(message.content()); + isUser = message.role().equals(ResponseInputItem.Message.Role.USER); + } else { + continue; + } + + if (!text.isEmpty()) { + lastAnyText = text; + if (isUser) { + lastUserText = text; + } + } + } + + return !lastUserText.isEmpty() ? lastUserText : lastAnyText; + } + + private static String textFromEasyContent(EasyInputMessage.Content content) { + if (content.isTextInput()) { + return content.asTextInput(); + } + if (content.isResponseInputMessageContentList()) { + return textFromContentList(content.asResponseInputMessageContentList()); + } + return ""; + } + + private static String textFromContentList(List contents) { + StringBuilder sb = new StringBuilder(); + for (ResponseInputContent content : contents) { + if (content.isInputText()) { + if (!sb.isEmpty()) { + sb.append('\n'); + } + sb.append(content.asInputText().text()); + } + } + return sb.toString(); } public static class AgentServerResponseCreateDeserializer extends JsonDeserializer { diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseEventStream.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseEventStream.java index e5f50049efe5..064c299b67dc 100644 --- a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseEventStream.java +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/AgentServerResponseEventStream.java @@ -5,6 +5,8 @@ import com.microsoft.agentserver.api.implementation.IdGenerator; import com.openai.core.JsonNull; +import com.openai.core.http.StreamResponse; +import com.openai.models.chat.completions.ChatCompletionChunk; import com.openai.models.responses.Response; import com.openai.models.responses.ResponseCodeInterpreterToolCall; import com.openai.models.responses.ResponseCompletedEvent; @@ -1093,6 +1095,13 @@ public String getItemId() { return itemId; } + @Override + public OutputMessageBuilder streamChatCompletion(StreamResponse streamResponse) { + return emitAdded() + .addTextPart(text -> text.emitAdded().streamDeltas(streamResponse)) + .emitDone(); + } + private ResponseOutputMessage buildMessage(ResponseOutputMessage.Status status) { // Build the created_by object matching Foundry platform expectations: // {"agent": {"type": "agent_id", "name": "", "version": ""}, "response_id": "..."} @@ -1188,5 +1197,21 @@ public TextPartBuilder emitDone(String text) { .build()))); return this; } + + @Override + public TextPartBuilder streamDeltas(StreamResponse streamResponse) { + try (streamResponse) { + streamResponse.stream().forEach(chunk -> + chunk.choices().forEach(choice -> + choice.delta().content().ifPresent(delta -> { + if (!delta.isEmpty()) { + emitDelta(delta); + } + }) + ) + ); + } + return this; + } } } diff --git a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseEventStream.java b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseEventStream.java index 5ae92de1c6ee..7f3930e40469 100644 --- a/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseEventStream.java +++ b/sdk/agentserver/azure-agentserver-api/src/main/java/com/microsoft/agentserver/api/ResponseEventStream.java @@ -3,6 +3,8 @@ package com.microsoft.agentserver.api; +import com.openai.core.http.StreamResponse; +import com.openai.models.chat.completions.ChatCompletionChunk; import com.openai.models.responses.Response; import com.openai.models.responses.ResponseCodeInterpreterToolCall; import com.openai.models.responses.ResponseCustomToolCall; @@ -243,6 +245,24 @@ interface OutputMessageBuilder { */ OutputMessageBuilder emitDone(); + /** + * Convenience method that streams a chat completion response as a single text + * message. Equivalent to: + *
{@code
+         * emitAdded()
+         *     .addTextPart(text -> text.emitAdded().streamDeltas(streamResponse))
+         *     .emitDone();
+         * }
+ * + *

Each token from the LLM is forwarded as a {@code response.output_text.delta} + * event in real time, giving callers a true streaming experience. + * + * @param streamResponse the streaming response from + * {@code client.chat().completions().createStreaming(params)}. + * @return this builder for chaining. + */ + OutputMessageBuilder streamChatCompletion(StreamResponse streamResponse); + /** * Returns the auto-generated item ID for this message. */ @@ -277,6 +297,29 @@ interface TextPartBuilder { * @return this builder. */ TextPartBuilder emitDone(String text); + + /** + * Consumes a streaming chat completion response and pipes each token into + * {@link #emitDelta(String)}. The stream is fully consumed (and closed) before + * this method returns, so callers should not close the {@link StreamResponse} + * themselves. {@link #emitDone} is not called by this method; the + * auto-done logic in {@code addTextPart} handles it, or the caller may chain + * {@code .emitDone(...)} explicitly. + * + *

{@code
+         * stream.addOutputMessage(msg -> msg
+         *     .emitAdded()
+         *     .addTextPart(text -> text
+         *         .emitAdded()
+         *         .streamDeltas(chatCompletionStreamResponse))
+         *     .emitDone());
+         * }
+ * + * @param streamResponse the streaming response from + * {@code client.chat().completions().createStreaming(params)}. + * @return this builder for chaining. + */ + TextPartBuilder streamDeltas(StreamResponse streamResponse); } // ── ResponseEventStream.Builder ───────────────────────────── diff --git a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/SerializationIntegrationTest.java b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/SerializationIntegrationTest.java index 2270840060b0..ca471056ef3f 100644 --- a/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/SerializationIntegrationTest.java +++ b/sdk/agentserver/azure-agentserver-api/src/test/java/com/microsoft/agentserver/api/SerializationIntegrationTest.java @@ -120,6 +120,7 @@ void deserializeStructuredInput() throws Exception { AgentServerCreateResponse request = mapper.readValue(json, AgentServerCreateResponse.class); assertNotNull(request.responseCreateParams()); assertTrue(request.responseCreateParams().input().isPresent()); + assertEquals("What is the weather?", request.inputText()); } @Test @@ -143,6 +144,54 @@ void deserializeMissingTypeDiscriminator() throws Exception { AgentServerCreateResponse request = mapper.readValue(json, AgentServerCreateResponse.class); assertNotNull(request.responseCreateParams()); assertTrue(request.responseCreateParams().input().isPresent()); + assertEquals("No type field here", request.inputText()); + } + + @Test + @DisplayName("inputText extracts string content from a message item (Foundry chat shape)") + void inputTextFromEasyMessageStringContent() throws Exception { + String json = """ + { + "input": [ + { "role": "user", "content": "hello" } + ], + "model": "gpt-4o" + } + """; + + AgentServerCreateResponse request = mapper.readValue(json, AgentServerCreateResponse.class); + assertEquals("hello", request.inputText()); + } + + @Test + @DisplayName("inputText returns the most recent user message in a multi-turn conversation") + void inputTextFromLastUserMessage() throws Exception { + String json = """ + { + "input": [ + { "role": "user", "content": [ {"type": "input_text", "text": "first question"} ] }, + { "role": "assistant", "content": [ {"type": "input_text", "text": "an answer"} ] }, + { "role": "user", "content": [ {"type": "input_text", "text": "second question"} ] } + ], + "model": "gpt-4o" + } + """; + + AgentServerCreateResponse request = mapper.readValue(json, AgentServerCreateResponse.class); + assertEquals("second question", request.inputText()); + } + + @Test + @DisplayName("inputText returns empty string when input is absent") + void inputTextEmptyWhenNoInput() throws Exception { + String json = """ + { + "model": "gpt-4o" + } + """; + + AgentServerCreateResponse request = mapper.readValue(json, AgentServerCreateResponse.class); + assertEquals("", request.inputText()); } @Test diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/Dockerfile b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/Dockerfile new file mode 100644 index 000000000000..57489a2afb52 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/Dockerfile @@ -0,0 +1,13 @@ +FROM eclipse-temurin:25-jre + +WORKDIR /app + +# Download the Application Insights Java agent +ADD https://github.com/microsoft/ApplicationInsights-Java/releases/download/3.6.2/applicationinsights-agent-3.6.2.jar applicationinsights-agent.jar + +COPY target/azure-agentserver-translator-sample-*-jar-with-dependencies.jar app.jar +COPY applicationinsights.json applicationinsights.json + +EXPOSE 8088 + +ENTRYPOINT ["sh", "-c", "exec java ${APPLICATIONINSIGHTS_CONNECTION_STRING:+-javaagent:/app/applicationinsights-agent.jar} -jar app.jar"] diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/applicationinsights.json b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/applicationinsights.json new file mode 100644 index 000000000000..4747098d95f3 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/applicationinsights.json @@ -0,0 +1,13 @@ +{ + "sampling": { + "percentage": 100 + }, + "instrumentation": { + "logging": { + "level": "DEBUG" + } + }, + "selfDiagnostics": { + "level": "DEBUG" + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/docker-compose.yml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/docker-compose.yml new file mode 100644 index 000000000000..5ba08a056562 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/docker-compose.yml @@ -0,0 +1,14 @@ +services: + translator-sample: + build: + context: . + dockerfile: Dockerfile + image: ${REGISTRY:-}translator-agent:latest + ports: + - "8088:8088" + + env_file: + - ../../.env + + environment: + - APPLICATIONINSIGHTS_CONNECTION_STRING=${APPLICATIONINSIGHTS_CONNECTION_STRING} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/pom.xml b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/pom.xml new file mode 100644 index 000000000000..30adc70a318d --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + azure-agentserver-translator-sample + azure-agentserver-translator-sample + Translator sample — demonstrates using an LLM (Azure OpenAI via the OpenAI Java SDK) to process requests inside an Agent Server handler + + + + + com.microsoft.agentserver + azure-agentserver-api + + + com.microsoft.agentserver + azure-agentserver-api-jaxrs + + + com.microsoft.agentserver + azure-agentserver-jersey + compile + + + + + com.openai + openai-java-client-okhttp + compile + + + + + org.slf4j + slf4j-simple + runtime + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + ${maven-assembly-plugin.version} + + + + com.microsoft.agentserver.Main + + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/run-sample.sh b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/run-sample.sh new file mode 100644 index 000000000000..484c6073f7ba --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/run-sample.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +../../mvnw clean package -DskipTests + +export REGISTRY=${REGISTRY:-} + +docker compose build +docker compose up --force-recreate -d + +# wait for the container to be ready +sleep 5 + +echo "=== Synchronous request ===" +curl -vvv -X POST http://localhost:8088/responses \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Hello, how are you today?", + "model": "gpt-4o" + }' -o - | json_pp + +sleep 1 + +echo "=== Streaming request ===" +curl -vvv -X POST http://localhost:8088/responses \ + -H "Accept: text/event-stream" \ + -H "Content-Type: application/json" \ + -d '{ + "input": "The quick brown fox jumps over the lazy dog.", + "model": "gpt-4o", + "stream": true + }' -o - + +docker compose down diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/src/main/java/com/microsoft/agentserver/ItalianTranslatorHandler.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/src/main/java/com/microsoft/agentserver/ItalianTranslatorHandler.java new file mode 100644 index 000000000000..9a2e00be1457 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/src/main/java/com/microsoft/agentserver/ItalianTranslatorHandler.java @@ -0,0 +1,121 @@ +package com.microsoft.agentserver; + +import com.microsoft.agentserver.api.AgentServerCreateResponse; +import com.microsoft.agentserver.api.CreateResponse; +import com.microsoft.agentserver.api.ResponseBuilder; +import com.microsoft.agentserver.api.ResponseContext; +import com.microsoft.agentserver.api.ResponseEventStream; +import com.microsoft.agentserver.api.ResponseHandler; +import com.openai.client.OpenAIClient; +import com.openai.core.http.StreamResponse; +import com.openai.models.ChatModel; +import com.openai.models.chat.completions.ChatCompletionChunk; +import com.openai.models.chat.completions.ChatCompletionCreateParams; +import com.openai.models.chat.completions.ChatCompletionSystemMessageParam; +import com.openai.models.chat.completions.ChatCompletionUserMessageParam; +import com.openai.models.responses.ResponseOutputText; + +import java.util.ArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * An Agent Server handler that translates any input text to Italian by calling + * the Azure OpenAI chat completions API directly via the stainless-generated OpenAI Java SDK. + * + *

The synchronous path ({@code createResponse}) calls the non-streaming completions endpoint + * and returns a single response. The streaming path ({@code createAsync}) uses + * {@code createStreaming} so each token from the LLM is forwarded to the caller as a delta + * event in real time. + * + *

The {@link OpenAIClient} and model deployment name are supplied by the caller (see + * {@link Main}), which builds them from the Foundry hosting environment. + */ +public class ItalianTranslatorHandler implements ResponseHandler { + + private static final String SYSTEM_PROMPT = + "You are a translator. Translate the user's text to Italian. " + + "Respond with only the Italian translation, nothing else."; + // Small fixed pool so concurrent streaming requests are not serialized behind one another. + private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(4); + + private final OpenAIClient client; + private final String deploymentName; + + /** + * Creates a handler that translates input to Italian using the supplied Azure OpenAI client. + * + * @param client the configured Azure OpenAI client. + * @param deploymentName the model deployment name to invoke. + */ + public ItalianTranslatorHandler(OpenAIClient client, String deploymentName) { + this.client = client; + this.deploymentName = deploymentName; + } + + private ChatCompletionCreateParams buildParams(String inputText) { + return ChatCompletionCreateParams.builder() + .model(ChatModel.of(deploymentName)) + .addMessage(ChatCompletionSystemMessageParam.builder() + .content(SYSTEM_PROMPT) + .build()) + .addMessage(ChatCompletionUserMessageParam.builder() + .content(inputText) + .build()) + .build(); + } + + /** + * Synchronous path: call the completions endpoint and return a single response. + */ + @Override + public CreateResponse createResponse(ResponseContext responseContext, AgentServerCreateResponse request) { + String inputText = request.inputText(); + if (inputText.isEmpty()) { + throw new IllegalArgumentException("No text input provided in the request"); + } + + var choices = client.chat().completions().create(buildParams(inputText)).choices(); + if (choices.isEmpty()) { + throw new IllegalStateException("The model returned no choices for the translation request"); + } + String translation = choices.get(0).message().content().orElse(""); + + ResponseOutputText responseOutputText = ResponseOutputText.builder() + .text(translation) + .annotations(new ArrayList<>()) + .build(); + + return new CreateResponse( + request.agent(), + ResponseBuilder.convertOutputToResponse(request, responseOutputText) + ); + } + + /** + * Streaming path: open a streaming chat completion and forward each token delta + * to the caller as it arrives, giving the client a real-time translation feed. + */ + @Override + public ResponseEventStream createAsync(ResponseContext responseContext, AgentServerCreateResponse request) { + String inputText = request.inputText(); + if (inputText.isEmpty()) { + throw new IllegalArgumentException("No text input provided in the request"); + } + + ResponseEventStream stream = ResponseEventStream.create(responseContext, request) + .emitCreated() + .emitInProgress(); + + EXECUTOR_SERVICE.execute(() -> { + try { + StreamResponse translatedMessages = client.chat().completions().createStreaming(buildParams(inputText)); + stream.addOutputMessage(msg -> msg.streamChatCompletion(translatedMessages)); + } finally { + stream.emitCompleted(); + } + }); + + return stream; + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/src/main/java/com/microsoft/agentserver/Main.java b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/src/main/java/com/microsoft/agentserver/Main.java new file mode 100644 index 000000000000..5fb3d9f21974 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-agentserver-translator-sample/src/main/java/com/microsoft/agentserver/Main.java @@ -0,0 +1,83 @@ +package com.microsoft.agentserver; + +import com.azure.core.credential.TokenCredential; +import com.azure.core.credential.TokenRequestContext; +import com.microsoft.agentserver.api.FoundryEnvironment; +import com.microsoft.agentserver.api.ResponsesApi; +import com.microsoft.agentserver.api.TrustStoreInstaller; +import com.microsoft.agentserver.server.jersey.JerseyAgentServerAdaptorService; +import com.openai.azure.AzureOpenAIServiceVersion; +import com.openai.azure.credential.AzureApiKeyCredential; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.credential.BearerTokenCredential; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Main { + private static final Logger LOGGER = LoggerFactory.getLogger(Main.class); + + /** Scope for Azure Cognitive Services (Azure OpenAI) access tokens. */ + private static final String COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default"; + + public static void main(String[] args) throws InterruptedException { + // Install the ADC egress proxy CA certificate so outbound TLS calls to Azure OpenAI + // succeed in hosted Foundry environments (avoids "PKIX path building failed"). + try { + TrustStoreInstaller.installAdcEgressProxyCertificate(LOGGER); + } catch (Exception e) { + LOGGER.warn("Failed to install ADC egress proxy CA certificate into the JVM truststore.", e); + } + + OpenAIClient client = buildClient(); + + JerseyAgentServerAdaptorService.buildAgent( + ResponsesApi.builder() + .responseHandler(new ItalianTranslatorHandler(client, FoundryEnvironment.MODEL_DEPLOYMENT_NAME)) + .build() + ); + + Thread.sleep(Long.MAX_VALUE); + } + + /** + * Builds the Azure OpenAI client. The endpoint and model deployment are supplied by the + * Foundry hosting environment; authentication prefers an {@code AZURE_CLIENT_KEY} for local + * development, otherwise a managed-identity bearer token from the hosting environment. + * + * @return a configured {@link OpenAIClient}. + * @throws IllegalStateException if {@code openAiEndpoint} is null or blank. + */ + private static OpenAIClient buildClient() { + if (FoundryEnvironment.OPENAI_ENDPOINT == null || FoundryEnvironment.OPENAI_ENDPOINT.isBlank()) { + throw new IllegalStateException( + "No endpoint configured. Set FOUNDRY_PROJECT_ENDPOINT or AZURE_OPENAI_ENDPOINT."); + } + + LOGGER.info("=== Resolved Configuration ==="); + LOGGER.info(" Agent Name: {}", FoundryEnvironment.AGENT_NAME); + LOGGER.info(" Model Deployment: {}", FoundryEnvironment.MODEL_DEPLOYMENT_NAME); + LOGGER.info(" Project Endpoint: {}", FoundryEnvironment.PROJECT_ENDPOINT); + LOGGER.info(" OpenAI Endpoint: {}", FoundryEnvironment.OPENAI_ENDPOINT); + LOGGER.info(" Hosted: {}", FoundryEnvironment.IS_HOSTED); + LOGGER.info("=== End Configuration ==="); + + OpenAIOkHttpClient.Builder clientBuilder = OpenAIOkHttpClient.builder() + .baseUrl(FoundryEnvironment.OPENAI_ENDPOINT) + .azureServiceVersion(AzureOpenAIServiceVersion.latestStableVersion()); + + String apiKey = System.getenv("AZURE_CLIENT_KEY"); + if (apiKey != null && !apiKey.isBlank()) { + LOGGER.info("Using API key for authentication"); + clientBuilder.credential(AzureApiKeyCredential.create(apiKey)); + } else { + LOGGER.info("Using managed-identity token for authentication"); + TokenCredential credential = FoundryEnvironment.resolveCredential(); + TokenRequestContext ctx = new TokenRequestContext().addScopes(COGNITIVE_SERVICES_SCOPE); + clientBuilder.credential( + BearerTokenCredential.create(() -> credential.getTokenSync(ctx).getToken())); + } + + return clientBuilder.build(); + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/.env.template b/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/.env.template new file mode 100644 index 000000000000..06ef1d96bbf1 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/.env.template @@ -0,0 +1,49 @@ +# ───────────────────────────────────────────────────────────────────── +# Environment template for run-sample.sh. +# +# Copy this file to `.env` and fill in the values, then run: +# +# cp .env.template .env +# ./run-sample.sh +# +# run-sample.sh sources `.env`. Every value uses the ${VAR:-default} +# form, so anything you export before running the script takes precedence +# over the defaults below. +# +# FOUNDRY_AGENT_CONTAINER_IMAGE=... ./run-sample.sh # override a single value +# ───────────────────────────────────────────────────────────────────── + +# ── Required ───────────────────────────────────────────────────────── + +# Azure AI Foundry project endpoint. +export FOUNDRY_PROJECT_ENDPOINT="${FOUNDRY_PROJECT_ENDPOINT:-https://.services.ai.azure.com/api/projects/}" + +# Financial-jersey container image reference (tag or digest). +export FOUNDRY_AGENT_CONTAINER_IMAGE="${FOUNDRY_AGENT_CONTAINER_IMAGE:-.azurecr.io/lc4jfinancialjersey:latest}" + +# ── Optional ───────────────────────────────────────────────────────── + +# Model deployment the financial agent should use. +export MODEL_DEPLOYMENT_NAME="${MODEL_DEPLOYMENT_NAME:-gpt-5.4}" + +# Hosted-agent name. +export AGENT_NAME="${AGENT_NAME:-java-financial-jersey-sdk-sample}" + +# Resource allocation for the hosted agent. +export AGENT_CPU="${AGENT_CPU:-2}" +export AGENT_MEMORY="${AGENT_MEMORY:-4Gi}" + +# Question to send to the financial agent. +export PROMPT="${PROMPT:-Transfer $12 from Alice to Bob.}" + +# Subscription that hosts the AI Services account (used in the printed RBAC commands). +# export AZURE_SUBSCRIPTION_ID="${AZURE_SUBSCRIPTION_ID:-}" + +# If "true", leaves the agent version and session in place after the run. +# export SKIP_CLEANUP="${SKIP_CLEANUP:-false}" + +# If "true", run-sample.sh builds and pushes FOUNDRY_AGENT_CONTAINER_IMAGE before running. +# export BUILD_IMAGE="${BUILD_IMAGE:-false}" + +# Docker build platform used when BUILD_IMAGE=true. +# export IMAGE_PLATFORM="${IMAGE_PLATFORM:-linux/amd64}" diff --git a/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/README.md b/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/README.md new file mode 100644 index 000000000000..2e6f0e5911dc --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/README.md @@ -0,0 +1,122 @@ +# Financial-jersey hosted-agent SDK client sample + +This sample shows the **client side** of Azure AI Foundry hosted agents. It takes the +[`azure-agentserver-langchain4j-financial-jersey-sample`](../financial-agent/azure-agentserver-langchain4j-financial-jersey-sample) +Agent Server, packaged as a container image, and: + +1. Deploys it as a **hosted agent** in an Azure AI Foundry project. +2. Waits for the agent version to become `ACTIVE`. +3. Creates a session pinned to that version. +4. Configures the agent endpoint to route all traffic to the version over the Responses protocol. +5. Invokes the agent with a financial question using an **agent-scoped OpenAI Responses client**. +6. Prints the response and then deletes the session and agent version. + +Everything is driven through the [`com.azure:azure-ai-agents`](https://central.sonatype.com/artifact/com.azure/azure-ai-agents) +Java SDK — there are no raw REST calls or shell scripts. + +## How it differs from the other financial samples + +The sibling `azure-agentserver-langchain4j-financial-*` modules implement the **server** (the agent +that runs inside the container). This module is the **caller** that deploys and drives one of those +servers as a hosted agent from Java. + +## Prerequisites + +- An Azure AI Foundry project and permission to create hosted agents in it. +- The financial-jersey container image built and pushed to a registry the project can pull from. +- Logged in with a credential `DefaultAzureCredential` can resolve (for example `az login`). +- A model deployment (for example `gpt-5.4`) available in the project. + +### Build and push the agent image + +From the financial-jersey sample directory: + +```bash +cd ../financial-agent/azure-agentserver-langchain4j-financial-jersey-sample +../../../mvnw -q package -DskipTests +docker build --platform linux/amd64 \ + -t .azurecr.io/lc4jfinancialjersey:latest . +docker push .azurecr.io/lc4jfinancialjersey:latest +``` + +## Configuration + +The sample is configured entirely through environment variables: + +| Variable | Required | Default | Description | +|---------------------------------|----------|------------------------------------|-------------------------------------------------------------------| +| `FOUNDRY_PROJECT_ENDPOINT` | yes | – | The Azure AI Foundry project endpoint. | +| `FOUNDRY_AGENT_CONTAINER_IMAGE` | yes | – | The financial-jersey container image reference (tag or digest). | +| `MODEL_DEPLOYMENT_NAME` | no | `gpt-5.4` | Model deployment the financial agent uses. Injected into the container. | +| `AGENT_NAME` | no | `java-financial-jersey-sdk-sample` | The hosted-agent name. | +| `AGENT_CPU` | no | `2` | vCPU allocation for the hosted agent. | +| `AGENT_MEMORY` | no | `4Gi` | Memory allocation for the hosted agent. | +| `PROMPT` | no | a sample expense-summary prompt | The question to send to the financial agent. | +| `SKIP_CLEANUP` | no | `false` | If `true`, leaves the agent version and session in place after the run. | +| `AZURE_SUBSCRIPTION_ID` | no | – | Subscription hosting the AI Services account. Used in the printed RBAC commands. | + +## Run + +The quickest way is the helper script, which builds the sample and runs it (and can optionally +build+push the agent image first with `BUILD_IMAGE=true`): + +```bash +FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" \ +FOUNDRY_AGENT_CONTAINER_IMAGE=".azurecr.io/lc4jfinancialjersey:latest" \ +MODEL_DEPLOYMENT_NAME="gpt-5.4" \ +./run-sample.sh +``` + +`run-sample.sh` automatically sources a `.env` file next to it if present. The provided `.env` +uses `${VAR:-default}` defaults, so any variable you export before running still takes precedence. +Edit `.env` to set your endpoint, image, and (optionally) `AZURE_SUBSCRIPTION_ID`, then just run +`./run-sample.sh` with no arguments. + +Or build and run manually: + +```bash +../../mvnw -q package -DskipTests \ + -pl azure-agentserver-samples/azure-ai-agents-sdk-client-sample -am + +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export FOUNDRY_AGENT_CONTAINER_IMAGE=".azurecr.io/lc4jfinancialjersey:latest" +export MODEL_DEPLOYMENT_NAME="gpt-5.4" + +java -jar target/azure-ai-agents-sdk-client-sample-1.0.0-SNAPSHOT-jar-with-dependencies.jar +``` + +## Notes + +- **Preview features.** Agent endpoints and sessions are preview features that only work with hosted + agents, so the `AgentsClientBuilder` is built with `allowPreview(true)`. +- **RBAC.** The hosted agent runs under a managed identity that needs access to the model. Once the + agent version is `ACTIVE`, the sample reads its `instanceIdentity.principalId` and prints the exact + `az role assignment create` command to run *before* the responses call — for example: + + ```bash + az account set --subscription + + ACCOUNT_ID=$(az cognitiveservices account list --subscription \ + --query "[?name==''].id | [0]" -o tsv) + + az role assignment create \ + --assignee-object-id \ + --assignee-principal-type ServicePrincipal \ + --role "Cognitive Services OpenAI User" \ + --scope "$ACCOUNT_ID" + ``` + + If invocation returns `500`/`server_error`, this missing RBAC is the usual cause (the container logs + a `401 PermissionDenied`). Grant the role, wait ~1-2 minutes for propagation, and re-run. +- **Keeping resources for debugging.** Set `SKIP_CLEANUP=true` to leave the agent version and session in + place after the run (for example to grant RBAC and re-invoke, or to stream container logs). By default + the sample deletes both. +- **Idempotent deployment.** Deploying is safe to repeat. If the agent does not exist it is created; + if it already exists a new version is added. The service assigns version numbers and deduplicates + identical definitions, so re-running with a **new** container image produces a new version, while + re-running with the **same** image reuses the current one. The sample logs which path it took. +- **HTTP client.** The sample uses the OkHttp-based Azure core HTTP client (`azure-core-http-okhttp`) + and excludes `azure-core-http-netty`, because the agentserver parent POM strips the transitive Netty + dependencies from the Netty client. +- **OpenAI SDK version.** `azure-ai-agents` 2.2.0 is built against `openai-java` 4.14.0, so this sample + pins that version explicitly to stay binary-compatible with the agent-scoped OpenAI client. diff --git a/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/pom.xml b/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/pom.xml new file mode 100644 index 000000000000..35eaf4b209b6 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/pom.xml @@ -0,0 +1,111 @@ + + + 4.0.0 + + com.microsoft.agentserver + azure-agentserver-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + azure-ai-agents-sdk-client-sample + azure-ai-agents-sdk-client-sample + Deploys the financial-jersey Agent Server sample as a hosted agent to Azure AI Foundry and invokes it using the com.azure:azure-ai-agents Java SDK. + + + + 2.2.0 + 4.14.0 + 1.13.5 + + + + + + com.azure + azure-ai-agents + ${azure-ai-agents.version} + + + + com.azure + azure-core-http-netty + + + + + + + com.azure + azure-core-http-okhttp + ${azure-core-http-okhttp.version} + + + + + com.azure + azure-identity + + + + + com.openai + openai-java + ${sample-openai.version} + + + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + + org.slf4j + slf4j-simple + runtime + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + ${maven-assembly-plugin.version} + + + + com.microsoft.agentserver.sample.financial.sdkclient.Main + + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + + + + diff --git a/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/run-sample.sh b/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/run-sample.sh new file mode 100755 index 000000000000..26d54f3d8732 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/run-sample.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +# +# Build and run the financial-jersey hosted-agent SDK client sample. +# +# This script: +# 1. (optional) Builds and pushes the financial-jersey container image to a registry. +# 2. Builds this sample's runnable jar. +# 3. Runs the sample, which deploys the image as a hosted agent to Azure AI Foundry +# and invokes it via the com.azure:azure-ai-agents SDK. +# +# ── Required environment variables ─────────────────────────────────── +# FOUNDRY_PROJECT_ENDPOINT Azure AI Foundry project endpoint. +# FOUNDRY_AGENT_CONTAINER_IMAGE Financial-jersey container image reference (tag or digest). +# +# ── Optional environment variables ─────────────────────────────────── +# MODEL_DEPLOYMENT_NAME Model deployment (default: gpt-5.4). +# AGENT_NAME Hosted-agent name (default: java-financial-jersey-sdk-sample). +# AGENT_CPU / AGENT_MEMORY Resource allocation (defaults: 2 / 4Gi). +# PROMPT Question to send to the agent. +# SKIP_CLEANUP If "true", leaves the agent version and session after the run. +# AZURE_SUBSCRIPTION_ID Subscription used in the printed RBAC commands. +# BUILD_IMAGE If "true", build+push FOUNDRY_AGENT_CONTAINER_IMAGE before running. +# IMAGE_PLATFORM Docker build platform (default: linux/amd64). +# +# ── Usage ──────────────────────────────────────────────────────────── +# FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" \ +# FOUNDRY_AGENT_CONTAINER_IMAGE=".azurecr.io/lc4jfinancialjersey:latest" \ +# ./run-sample.sh +# +# # Also (re)build and push the agent image first: +# BUILD_IMAGE=true FOUNDRY_PROJECT_ENDPOINT=... FOUNDRY_AGENT_CONTAINER_IMAGE=... ./run-sample.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +MVNW="${SCRIPT_DIR}/../../mvnw" +JERSEY_SAMPLE_DIR="${SCRIPT_DIR}/../financial-agent/azure-agentserver-langchain4j-financial-jersey-sample" +IMAGE_PLATFORM="${IMAGE_PLATFORM:-linux/amd64}" + +# ── Load .env (values use ${VAR:-default}, so caller env still wins) ── +if [[ -f "${SCRIPT_DIR}/.env" ]]; then + # shellcheck disable=SC1091 + source "${SCRIPT_DIR}/.env" +fi + +# ── Validate required inputs ───────────────────────────────────────── +: "${FOUNDRY_PROJECT_ENDPOINT:?Set FOUNDRY_PROJECT_ENDPOINT to your Azure AI Foundry project endpoint}" +: "${FOUNDRY_AGENT_CONTAINER_IMAGE:?Set FOUNDRY_AGENT_CONTAINER_IMAGE to the financial-jersey image reference}" + +# ── (optional) Build and push the financial-jersey agent image ─────── +if [[ "${BUILD_IMAGE:-false}" == "true" ]]; then + echo "=== Building financial-jersey jar ===" + "${MVNW}" -f "${JERSEY_SAMPLE_DIR}/pom.xml" -am package -DskipTests + + # Log in to the registry host of the image reference (assumes Azure Container Registry). + REGISTRY_HOST="${FOUNDRY_AGENT_CONTAINER_IMAGE%%/*}" + if [[ "${REGISTRY_HOST}" == *.azurecr.io ]]; then + ACR_NAME="${REGISTRY_HOST%%.azurecr.io}" + echo "=== Logging in to ACR '${ACR_NAME}' ===" + az acr login -n "${ACR_NAME}" + fi + + # Strip any digest suffix; build/push against the tag portion only. + BUILD_IMAGE_REF="${FOUNDRY_AGENT_CONTAINER_IMAGE%@*}" + echo "=== Building image ${BUILD_IMAGE_REF} (${IMAGE_PLATFORM}) ===" + docker build --platform "${IMAGE_PLATFORM}" -t "${BUILD_IMAGE_REF}" "${JERSEY_SAMPLE_DIR}" + + echo "=== Pushing image ${BUILD_IMAGE_REF} ===" + docker push "${BUILD_IMAGE_REF}" +fi + +# ── Build this sample ──────────────────────────────────────────────── +echo "=== Building SDK client sample ===" +"${MVNW}" -f "${SCRIPT_DIR}/pom.xml" package -DskipTests + +JAR="" +shopt -s nullglob +for candidate in "${SCRIPT_DIR}"/target/azure-ai-agents-sdk-client-sample-*-jar-with-dependencies.jar; do + JAR="${candidate}" + break +done +shopt -u nullglob +if [[ -z "${JAR}" || ! -f "${JAR}" ]]; then + echo "Error: runnable jar not found under target/." >&2 + exit 1 +fi + +# ── Run ────────────────────────────────────────────────────────────── +echo "=== Running SDK client sample ===" +exec java -jar "${JAR}" diff --git a/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/src/main/java/com/microsoft/agentserver/sample/financial/sdkclient/Main.java b/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/src/main/java/com/microsoft/agentserver/sample/financial/sdkclient/Main.java new file mode 100644 index 000000000000..020896356219 --- /dev/null +++ b/sdk/agentserver/azure-agentserver-samples/azure-ai-agents-sdk-client-sample/src/main/java/com/microsoft/agentserver/sample/financial/sdkclient/Main.java @@ -0,0 +1,324 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.agentserver.sample.financial.sdkclient; + +import com.azure.ai.agents.AgentsClient; +import com.azure.ai.agents.AgentsClientBuilder; +import com.azure.ai.agents.models.AgentEndpointConfig; +import com.azure.ai.agents.models.AgentEndpointProtocol; +import com.azure.ai.agents.models.AgentSessionResource; +import com.azure.ai.agents.models.AgentVersionDetails; +import com.azure.ai.agents.models.AgentVersionStatus; +import com.azure.ai.agents.models.ContainerConfiguration; +import com.azure.ai.agents.models.CreateAgentVersionInput; +import com.azure.ai.agents.models.FixedRatioVersionSelectionRule; +import com.azure.ai.agents.models.HostedAgentDefinition; +import com.azure.ai.agents.models.ProtocolConfiguration; +import com.azure.ai.agents.models.ProtocolVersionRecord; +import com.azure.ai.agents.models.ResponsesProtocolConfiguration; +import com.azure.ai.agents.models.UpdateAgentDetailsOptions; +import com.azure.ai.agents.models.VersionSelector; +import com.azure.core.exception.ResourceNotFoundException; +import com.azure.core.http.rest.RequestOptions; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.openai.client.OpenAIClient; +import com.openai.core.JsonValue; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; + +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * End-to-end sample that deploys the {@code azure-agentserver-langchain4j-financial-jersey-sample} + * container image as a hosted agent in Azure AI Foundry and then invokes it through the + * {@code com.azure:azure-ai-agents} Java SDK. + * + *

The flow mirrors {@code AgentEndpointSample} from the azure-ai-agents SDK samples:

+ *
    + *
  1. Create a hosted agent version from the financial-jersey container image.
  2. + *
  3. Poll until the version is {@code ACTIVE}.
  4. + *
  5. Create a session pinned to that version.
  6. + *
  7. Configure the agent endpoint to route all traffic to the version via the Responses protocol.
  8. + *
  9. Invoke the agent using an agent-scoped OpenAI Responses client.
  10. + *
  11. Delete the session and agent version.
  12. + *
+ * + *

Agent endpoints and sessions are preview features and only work with hosted agents, so the + * {@link AgentsClientBuilder} is built with {@code allowPreview(true)}.

+ * + *

Environment variables:

+ *
    + *
  • {@code FOUNDRY_PROJECT_ENDPOINT} (required) - the Azure AI Foundry project endpoint.
  • + *
  • {@code FOUNDRY_AGENT_CONTAINER_IMAGE} (required) - the financial-jersey container image reference + * (for example {@code javaaiacr3pwp6x.azurecr.io/lc4jfinancialjersey:latest}).
  • + *
  • {@code MODEL_DEPLOYMENT_NAME} (optional, default {@code gpt-5.4}) - the model deployment the + * financial agent should use. Injected into the container environment.
  • + *
  • {@code AGENT_NAME} (optional, default {@code java-financial-jersey-sdk-sample}).
  • + *
  • {@code AGENT_CPU} (optional, default {@code 2}) and {@code AGENT_MEMORY} (optional, default {@code 4Gi}).
  • + *
  • {@code PROMPT} (optional) - the question to ask the financial agent.
  • + *
+ */ +public final class Main { + + private static final String DEFAULT_AGENT_NAME = "java-financial-jersey-sdk-sample"; + private static final String DEFAULT_MODEL_DEPLOYMENT = "gpt-5.4"; + private static final String DEFAULT_CPU = "2"; + private static final String DEFAULT_MEMORY = "4Gi"; + private static final String DEFAULT_PROMPT = "Transfer $12 from Alice to Bob."; + + private static final int MAX_POLL_ATTEMPTS = 60; + private static final Duration POLL_INTERVAL = Duration.ofSeconds(10); + + private Main() { + } + + public static void main(String[] args) { + String endpoint = requireEnv("FOUNDRY_PROJECT_ENDPOINT"); + String image = requireEnv("FOUNDRY_AGENT_CONTAINER_IMAGE"); + String agentName = getEnvOrDefault("AGENT_NAME", DEFAULT_AGENT_NAME); + String modelDeployment = getEnvOrDefault("MODEL_DEPLOYMENT_NAME", DEFAULT_MODEL_DEPLOYMENT); + String cpu = getEnvOrDefault("AGENT_CPU", DEFAULT_CPU); + String memory = getEnvOrDefault("AGENT_MEMORY", DEFAULT_MEMORY); + String prompt = getEnvOrDefault("PROMPT", DEFAULT_PROMPT); + + AgentsClientBuilder builder = new AgentsClientBuilder() + .credential(new DefaultAzureCredentialBuilder().build()) + .endpoint(endpoint); + AgentsClient agentsClient = builder.allowPreview(true).buildAgentsClient(); + + AgentVersionDetails agent = null; + AgentSessionResource session = null; + try { + agent = createFinancialAgentVersion(agentsClient, agentName, image, modelDeployment, cpu, memory); + AgentVersionDetails activeVersion = waitForAgentVersionActive(agentsClient, agentName, agent.getVersion()); + + session = createSession(agentsClient, agentName, agent.getVersion()); + configureEndpoint(agentsClient, agentName, agent.getVersion()); + + // The hosted agent calls Azure OpenAI under its own managed identity. Print the exact + // role assignments that identity needs, so the RBAC can be granted before invoking. + printManagedIdentityRoleGuidance(endpoint, activeVersion); + + OpenAIClient openAIClient = builder.buildAgentScopedOpenAIClient(agentName); + System.out.printf("Invoking agent %s with prompt: %s%n", agentName, prompt); + Response response = openAIClient.responses().create(ResponseCreateParams.builder() + .input(prompt) + .putAdditionalBodyProperty("agent_session_id", JsonValue.from(session.getAgentSessionId())) + .build()); + + printResponseOutput(response); + } finally { + if (getEnvOrDefault("SKIP_CLEANUP", "false").equalsIgnoreCase("true")) { + System.out.printf("SKIP_CLEANUP=true; leaving agent '%s' version %s and session %s in place.%n", + agentName, agent == null ? "?" : agent.getVersion(), + session == null ? "?" : session.getAgentSessionId()); + } else { + cleanup(agentsClient, agentName, agent, session); + } + } + } + + private static AgentVersionDetails createFinancialAgentVersion(AgentsClient agentsClient, String agentName, + String image, String modelDeployment, String cpu, String memory) { + Map containerEnv = new HashMap<>(); + containerEnv.put("MODEL_DEPLOYMENT_NAME", modelDeployment); + + HostedAgentDefinition definition = new HostedAgentDefinition(cpu, memory) + .setContainerConfiguration(new ContainerConfiguration(image)) + .setEnvironmentVariables(containerEnv) + .setProtocolVersions(Collections.singletonList( + new ProtocolVersionRecord(AgentEndpointProtocol.RESPONSES, "1.0.0"))); + + Map metadata = new HashMap<>(); + metadata.put("enableVnextExperience", "true"); + + CreateAgentVersionInput input = new CreateAgentVersionInput(definition) + .setMetadata(metadata) + .setDescription("Financial-jersey hosted agent deployed by the Azure AI Agents Java SDK sample."); + + // Idempotent deployment: creating an agent version implicitly creates the agent if it does not + // already exist, and otherwise adds a new version to the existing agent. The service assigns the + // version number and deduplicates identical definitions, so re-running with a new container image + // produces a new version while re-running with the same image reuses the current one. + if (agentExists(agentsClient, agentName)) { + System.out.printf("Agent '%s' already exists; deploying a new version with image %s%n", + agentName, image); + } else { + System.out.printf("Agent '%s' does not exist; creating it with image %s%n", agentName, image); + } + + AgentVersionDetails agent = agentsClient.createAgentVersion(agentName, input); + System.out.printf("Agent version ready (name: %s, version: %s)%n", agent.getName(), agent.getVersion()); + return agent; + } + + private static boolean agentExists(AgentsClient agentsClient, String agentName) { + try { + agentsClient.getAgent(agentName); + return true; + } catch (ResourceNotFoundException e) { + return false; + } + } + + private static AgentSessionResource createSession(AgentsClient agentsClient, String agentName, String version) { + Map versionIndicator = new HashMap<>(); + versionIndicator.put("agent_version", version); + versionIndicator.put("type", "version_ref"); + Map request = new HashMap<>(); + request.put("version_indicator", versionIndicator); + + AgentSessionResource session = agentsClient + .createSessionWithResponse(agentName, BinaryData.fromObject(request), new RequestOptions()) + .getValue().toObject(AgentSessionResource.class); + System.out.printf("Session created (id: %s, status: %s)%n", session.getAgentSessionId(), session.getStatus()); + return session; + } + + private static void configureEndpoint(AgentsClient agentsClient, String agentName, String version) { + AgentEndpointConfig endpointConfig = new AgentEndpointConfig() + .setVersionSelector(new VersionSelector().setVersionSelectionRules(Collections.singletonList( + new FixedRatioVersionSelectionRule(100).setAgentVersion(version)))) + .setProtocolConfiguration(new ProtocolConfiguration().setResponses(new ResponsesProtocolConfiguration())); + + agentsClient.updateAgentDetails(agentName, new UpdateAgentDetailsOptions().setAgentEndpoint(endpointConfig)); + System.out.printf("Agent endpoint configured for agent: %s%n", agentName); + } + + private static AgentVersionDetails waitForAgentVersionActive(AgentsClient agentsClient, String agentName, + String version) { + for (int attempt = 1; attempt <= MAX_POLL_ATTEMPTS; attempt++) { + sleep(POLL_INTERVAL); + AgentVersionDetails details = agentsClient.getAgentVersionDetails(agentName, version); + AgentVersionStatus status = details.getStatus(); + System.out.printf("Agent version status: %s (attempt %d)%n", status, attempt); + if (AgentVersionStatus.ACTIVE == status) { + return details; + } + if (AgentVersionStatus.FAILED == status) { + throw new RuntimeException("Agent version provisioning failed: " + version); + } + } + throw new RuntimeException("Timed out waiting for agent version to become active: " + version); + } + + /** + * Prints the exact {@code az role assignment create} command needed to grant the hosted agent's + * managed identity access to Azure OpenAI. The financial agent calls the model under this identity, + * so without this role the responses endpoint returns {@code 500 server_error} (caused by a + * {@code 401 PermissionDenied} inside the container). + */ + private static void printManagedIdentityRoleGuidance(String endpoint, AgentVersionDetails activeVersion) { + String principalId = activeVersion.getInstanceIdentity() == null + ? null : activeVersion.getInstanceIdentity().getPrincipalId(); + String accountName = accountNameFromEndpoint(endpoint); + + String assignee = principalId == null ? "" : principalId; + String subscriptionId = getEnvOrDefault("AZURE_SUBSCRIPTION_ID", ""); + + System.out.println(); + System.out.println("============================================================================"); + System.out.println(" Managed identity role assignment required before invoking the agent"); + System.out.println("============================================================================"); + System.out.printf(" Agent managed identity principal id: %s%n", assignee); + System.out.println(" The hosted agent calls Azure OpenAI under this identity. Grant it the role"); + System.out.println(" below (idempotent - safe to re-run), then wait ~1-2 min for RBAC to"); + System.out.println(" propagate before the responses call."); + System.out.println(); + System.out.println(" # Target the subscription that hosts the AI Services account:"); + System.out.printf(" az account set --subscription \"%s\"%n", subscriptionId); + System.out.println(); + System.out.println(" # Resolve the AI Services account ARM resource id:"); + System.out.printf(" ACCOUNT_ID=$(az cognitiveservices account list --subscription \"%s\" " + + "--query \"[?name=='%s'].id | [0]\" -o tsv)%n", subscriptionId, accountName); + System.out.println(); + System.out.println(" # Cognitive Services OpenAI User on the account (data-plane model access):"); + System.out.println(" az role assignment create \\"); + System.out.printf(" --assignee-object-id %s \\%n", assignee); + System.out.println(" --assignee-principal-type ServicePrincipal \\"); + System.out.println(" --role \"Cognitive Services OpenAI User\" \\"); + System.out.println(" --scope \"$ACCOUNT_ID\""); + System.out.println("============================================================================"); + System.out.println(); + } + + private static String accountNameFromEndpoint(String endpoint) { + try { + String host = java.net.URI.create(endpoint).getHost(); + if (host != null && host.contains(".")) { + return host.substring(0, host.indexOf('.')); + } + } catch (RuntimeException ignored) { + // Fall through to placeholder. + } + return ""; + } + + private static void printResponseOutput(Response response) { + boolean[] printedAny = { false }; + for (ResponseOutputItem outputItem : response.output()) { + if (outputItem.message().isPresent()) { + ResponseOutputMessage message = outputItem.message().get(); + message.content().forEach(content -> content.outputText() + .ifPresent(text -> { + System.out.println("Response output: " + text.text()); + printedAny[0] = true; + })); + } + } + if (!printedAny[0]) { + System.out.println("No text output was returned. If the invoke failed with a 500/server_error, " + + "verify the agent's managed identity has the role printed above."); + } + } + + private static void cleanup(AgentsClient agentsClient, String agentName, AgentVersionDetails agent, + AgentSessionResource session) { + if (session != null) { + try { + agentsClient.deleteSession(agentName, session.getAgentSessionId()); + System.out.printf("Session with id: %s deleted.%n", session.getAgentSessionId()); + } catch (ResourceNotFoundException ignored) { + // Already deleted. + } + } + if (agent != null) { + try { + agentsClient.deleteAgentVersion(agentName, agent.getVersion()); + System.out.printf("Agent version %s deleted.%n", agent.getVersion()); + } catch (ResourceNotFoundException ignored) { + // Already deleted. + } + } + } + + private static String requireEnv(String name) { + String value = Configuration.getGlobalConfiguration().get(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Required environment variable is not set: " + name); + } + return value; + } + + private static String getEnvOrDefault(String name, String defaultValue) { + String value = Configuration.getGlobalConfiguration().get(name); + return (value == null || value.isBlank()) ? defaultValue : value; + } + + private static void sleep(Duration duration) { + try { + Thread.sleep(duration.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for hosted agent provisioning.", e); + } + } +} diff --git a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/java/com/microsoft/agentserver/sample/financial/jersey/Main.java b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/java/com/microsoft/agentserver/sample/financial/jersey/Main.java index ac3f54917ef5..4c43edbbc96b 100644 --- a/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/java/com/microsoft/agentserver/sample/financial/jersey/Main.java +++ b/sdk/agentserver/azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample/src/main/java/com/microsoft/agentserver/sample/financial/jersey/Main.java @@ -58,7 +58,6 @@ public static void main(String[] args) throws InterruptedException { Thread.currentThread().join(); } - private static AzureOpenAiChatModel getAzureOpenAiChatModel(String openAiEndpoint) { AzureOpenAiChatModel.Builder modelBuilder = AzureOpenAiChatModel.builder() .deploymentName(FoundryEnvironment.MODEL_DEPLOYMENT_NAME); diff --git a/sdk/agentserver/pom.xml b/sdk/agentserver/pom.xml index 29422258ad9c..98cd6f12d045 100644 --- a/sdk/agentserver/pom.xml +++ b/sdk/agentserver/pom.xml @@ -23,12 +23,14 @@ azure-agentserver-samples/azure-agentserver-example-framework-integrations/azure-agentserver-agentframeworks/azure-agentserver-integration-tests azure-agentserver-samples/azure-agentserver-echo-sample + azure-agentserver-samples/azure-agentserver-translator-sample azure-agentserver-samples/azure-agentserver-langchain4j-sample azure-agentserver-samples/azure-agentserver-langchain4j-spring-sample azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-sample azure-agentserver-samples/financial-agent/azure-agentserver-langchain4j-financial-jersey-sample + azure-agentserver-samples/azure-ai-agents-sdk-client-sample azure-agentserver-samples/azure-agentserver-doc-samples