55package com .agentclientprotocol .sdk .fixtures ;
66
77import java .time .Duration ;
8+ import java .util .List ;
89import java .util .Locale ;
910import java .util .Map ;
1011import java .util .concurrent .ConcurrentHashMap ;
12+ import java .util .concurrent .ExecutorService ;
13+ import java .util .concurrent .Executors ;
1114import java .util .concurrent .atomic .AtomicInteger ;
1215
1316import com .agentclientprotocol .sdk .agent .AcpAgent ;
1417import com .agentclientprotocol .sdk .agent .AcpAgentFactory ;
1518import com .agentclientprotocol .sdk .agent .transport .StreamableHttpAcpAgentTransport ;
1619import com .agentclientprotocol .sdk .json .AcpJsonMapper ;
1720import com .agentclientprotocol .sdk .spec .AcpSchema ;
21+ import org .springframework .ai .chat .messages .SystemMessage ;
22+ import org .springframework .ai .chat .messages .UserMessage ;
23+ import org .springframework .ai .chat .model .ChatResponse ;
24+ import org .springframework .ai .chat .model .Generation ;
25+ import org .springframework .ai .chat .prompt .Prompt ;
26+ import org .springframework .ai .openai .OpenAiChatModel ;
27+ import org .springframework .ai .openai .OpenAiChatOptions ;
28+ import org .springframework .ai .openai .api .OpenAiApi ;
1829import reactor .core .publisher .Mono ;
30+ import reactor .core .scheduler .Scheduler ;
31+ import reactor .core .scheduler .Schedulers ;
1932
2033/**
2134 * Small runnable ACP agent server for manually exercising the Streamable HTTP transport.
@@ -28,6 +41,13 @@ public final class StreamableHttpAgentDemoServer {
2841
2942 private static final Duration STOP_TIMEOUT = Duration .ofSeconds (5 );
3043
44+ private static final String OPENAI_SYSTEM_PROMPT = """
45+ You are a small ACP demo agent running inside the Java SDK Streamable HTTP fixture.
46+ Answer concisely. If the user asks about implementation details, say that this
47+ fixture is exercising the ACP Streamable HTTP transport, not providing a full
48+ production agent runtime.
49+ """ ;
50+
3151 private StreamableHttpAgentDemoServer () {
3252 }
3353
@@ -50,6 +70,15 @@ public static void main(String[] args) {
5070
5171 Map <String , String > sessionCwds = new ConcurrentHashMap <>();
5272 AtomicInteger sessionCounter = new AtomicInteger ();
73+ PromptBackend promptBackend ;
74+ try {
75+ promptBackend = options .backend ().create ();
76+ }
77+ catch (IllegalArgumentException e ) {
78+ System .err .println (e .getMessage ());
79+ System .exit (2 );
80+ return ;
81+ }
5382
5483 AcpAgentFactory agentFactory = AcpAgentFactory .async (transport -> AcpAgent .async (transport )
5584 .requestTimeout (Duration .ofMinutes (2 ))
@@ -75,7 +104,7 @@ public static void main(String[] args) {
75104 "Permission " + (allowed ? "granted" : "denied" ) + ". Prompt: " + normalized ))
76105 .onErrorResume (error -> context .sendMessage (
77106 "Permission request failed in demo server: " + error .getMessage ()))
78- : context . sendMessage ( "Demo agent received: " + normalized + " [ cwd=" + cwd + "]" );
107+ : promptBackend . generate ( normalized , request . sessionId (), cwd ). flatMap ( context :: sendMessage );
79108 return response .thenReturn (AcpSchema .PromptResponse .endTurn ());
80109 })
81110 .cancelHandler (notification -> {
@@ -88,8 +117,14 @@ public static void main(String[] args) {
88117 AcpJsonMapper .createDefault (), agentFactory )
89118 .routingMode (options .routingMode ());
90119
91- Runtime .getRuntime ().addShutdownHook (new Thread (() -> server .closeGracefully ().block (STOP_TIMEOUT ),
92- "acp-demo-shutdown" ));
120+ Runtime .getRuntime ().addShutdownHook (new Thread (() -> {
121+ try {
122+ server .closeGracefully ().block (STOP_TIMEOUT );
123+ }
124+ finally {
125+ promptBackend .close ();
126+ }
127+ }, "acp-demo-shutdown" ));
93128
94129 server .start ().block (START_TIMEOUT );
95130 System .out .println ("ACP Streamable HTTP demo agent listening at http://127.0.0.1:" + server .getPort ()
@@ -103,29 +138,39 @@ private static void printUsage() {
103138 Usage: java -jar acp-streamable-http-agent-server.jar [options]
104139
105140 Options:
106- --port <port> Port to listen on. Defaults to 8080.
107- --path <path> ACP endpoint path. Defaults to /acp.
108- --strict Use strict transport routing.
109- --compatible Use compatible transport routing. This is the default.
110- -h, --help Show this help.
141+ --port <port> Port to listen on. Defaults to 8080.
142+ --path <path> ACP endpoint path. Defaults to /acp.
143+ --backend <backend> Agent backend: echo or spring-ai-openai. Defaults to echo.
144+ --openai-model <model> OpenAI model for spring-ai-openai. Defaults to OPENAI_MODEL or gpt-4o-mini.
145+ --strict Use strict transport routing.
146+ --compatible Use compatible transport routing. This is the default.
147+ -h, --help Show this help.
148+
149+ Environment:
150+ OPENAI_API_KEY Required when --backend spring-ai-openai is used.
151+ OPENAI_MODEL Optional default model for --backend spring-ai-openai.
111152 """ );
112153 }
113154
114155 private record Options (int port , String path , StreamableHttpAcpAgentTransport .RoutingMode routingMode ,
115- boolean help ) {
156+ Backend backend , boolean help ) {
116157
117158 static Options parse (String [] args ) {
118159 int port = 8080 ;
119160 String path = StreamableHttpAcpAgentTransport .DEFAULT_ACP_PATH ;
120161 StreamableHttpAcpAgentTransport .RoutingMode routingMode =
121162 StreamableHttpAcpAgentTransport .RoutingMode .COMPATIBLE ;
163+ String backendName = "echo" ;
164+ String openAiModel = null ;
122165 boolean help = false ;
123166
124167 for (int i = 0 ; i < args .length ; i ++) {
125168 String arg = args [i ];
126169 switch (arg ) {
127170 case "--port" -> port = parsePort (requireValue (args , ++i , "--port" ));
128171 case "--path" -> path = requireValue (args , ++i , "--path" );
172+ case "--backend" -> backendName = requireValue (args , ++i , "--backend" );
173+ case "--openai-model" -> openAiModel = requireValue (args , ++i , "--openai-model" );
129174 case "--strict" -> routingMode = StreamableHttpAcpAgentTransport .RoutingMode .STRICT ;
130175 case "--compatible" -> routingMode = StreamableHttpAcpAgentTransport .RoutingMode .COMPATIBLE ;
131176 case "-h" , "--help" -> help = true ;
@@ -136,7 +181,8 @@ static Options parse(String[] args) {
136181 if (!path .startsWith ("/" )) {
137182 throw new IllegalArgumentException ("--path must start with /" );
138183 }
139- return new Options (port , path , routingMode , help );
184+ Backend backend = Backend .parse (backendName , openAiModel );
185+ return new Options (port , path , routingMode , backend , help );
140186 }
141187
142188 private static String requireValue (String [] args , int index , String option ) {
@@ -161,4 +207,134 @@ private static int parsePort(String value) {
161207
162208 }
163209
210+ private sealed interface Backend permits EchoBackend , SpringAiOpenAiBackend {
211+
212+ PromptBackend create ();
213+
214+ static Backend echo () {
215+ return new EchoBackend ();
216+ }
217+
218+ static Backend springAiOpenAi (String model ) {
219+ return new SpringAiOpenAiBackend (model );
220+ }
221+
222+ static Backend parse (String value , String openAiModel ) {
223+ return switch (value ) {
224+ case "echo" -> echo ();
225+ case "spring-ai-openai" -> springAiOpenAi (openAiModel );
226+ default -> throw new IllegalArgumentException ("Unknown backend: " + value );
227+ };
228+ }
229+
230+ }
231+
232+ @ FunctionalInterface
233+ private interface PromptBackend {
234+
235+ Mono <String > generate (String prompt , String sessionId , String cwd );
236+
237+ default void close () {
238+ }
239+
240+ }
241+
242+ private record EchoBackend () implements Backend {
243+
244+ @ Override
245+ public PromptBackend create () {
246+ return new EchoPromptBackend ();
247+ }
248+
249+ }
250+
251+ private static final class EchoPromptBackend implements PromptBackend {
252+
253+ @ Override
254+ public Mono <String > generate (String prompt , String sessionId , String cwd ) {
255+ return Mono .just ("Demo agent received: " + prompt + " [cwd=" + cwd + "]" );
256+ }
257+
258+ }
259+
260+ private record SpringAiOpenAiBackend (String model ) implements Backend {
261+
262+ @ Override
263+ public PromptBackend create () {
264+ String apiKey = System .getenv ("OPENAI_API_KEY" );
265+ if (apiKey == null || apiKey .isBlank ()) {
266+ throw new IllegalArgumentException (
267+ "OPENAI_API_KEY is required when --backend spring-ai-openai is used" );
268+ }
269+
270+ OpenAiApi openAiApi = OpenAiApi .builder ().apiKey (apiKey ).build ();
271+ OpenAiChatOptions chatOptions = OpenAiChatOptions .builder ()
272+ .model (resolveOpenAiModel (this .model ))
273+ .temperature (0.2 )
274+ .maxTokens (800 )
275+ .build ();
276+ OpenAiChatModel chatModel = OpenAiChatModel .builder ()
277+ .openAiApi (openAiApi )
278+ .defaultOptions (chatOptions )
279+ .build ();
280+
281+ return new SpringAiOpenAiPromptBackend (chatModel );
282+ }
283+
284+ }
285+
286+ private static final class SpringAiOpenAiPromptBackend implements PromptBackend {
287+
288+ private final OpenAiChatModel chatModel ;
289+
290+ private final ExecutorService executorService ;
291+
292+ private final Scheduler scheduler ;
293+
294+ private SpringAiOpenAiPromptBackend (OpenAiChatModel chatModel ) {
295+ this .chatModel = chatModel ;
296+ AtomicInteger threadCounter = new AtomicInteger ();
297+ this .executorService = Executors .newCachedThreadPool (task -> {
298+ Thread thread = new Thread (task , "acp-demo-openai-" + threadCounter .incrementAndGet ());
299+ thread .setDaemon (true );
300+ return thread ;
301+ });
302+ this .scheduler = Schedulers .fromExecutorService (this .executorService , "acp-demo-openai" );
303+ }
304+
305+ @ Override
306+ public Mono <String > generate (String prompt , String sessionId , String cwd ) {
307+ return Mono .fromCallable (() -> generatePrompt (prompt , sessionId , cwd )).subscribeOn (this .scheduler );
308+ }
309+
310+ @ Override
311+ public void close () {
312+ this .scheduler .dispose ();
313+ this .executorService .shutdownNow ();
314+ }
315+
316+ private String generatePrompt (String prompt , String sessionId , String cwd ) {
317+ ChatResponse response = chatModel .call (new Prompt (List .of (new SystemMessage (OPENAI_SYSTEM_PROMPT ),
318+ new UserMessage ("Session: " + sessionId + "\n CWD: " + cwd + "\n \n User prompt:\n " + prompt ))));
319+ Generation generation = response .getResult ();
320+ if (generation == null || generation .getOutput () == null || generation .getOutput ().getText () == null
321+ || generation .getOutput ().getText ().isBlank ()) {
322+ return "(OpenAI returned an empty response)" ;
323+ }
324+ return generation .getOutput ().getText ();
325+ }
326+
327+ }
328+
329+ private static String resolveOpenAiModel (String model ) {
330+ if (model != null && !model .isBlank ()) {
331+ return model ;
332+ }
333+ String envModel = System .getenv ("OPENAI_MODEL" );
334+ if (envModel != null && !envModel .isBlank ()) {
335+ return envModel ;
336+ }
337+ return "gpt-4o-mini" ;
338+ }
339+
164340}
0 commit comments