1010import java .io .BufferedReader ;
1111import java .io .InputStream ;
1212import java .io .InputStreamReader ;
13+ import java .net .CookieManager ;
14+ import java .net .CookiePolicy ;
1315import java .net .URI ;
1416import java .net .URLEncoder ;
1517import java .net .http .HttpClient ;
2426/**
2527 * Synchronous client for the DeepSQL Agent webui API, reached over the compose
2628 * network (internal — not via the gated /agent-api proxy). Used by non-browser
27- * channels (Slack, etc.) that need a single final answer rather than a live SSE
29+ * channels (Slack, CLI, etc.) that need a single final answer rather than a live SSE
2830 * stream: it starts a turn, consumes the stream server-side, and returns the
2931 * assembled text plus a compact tool-step summary.
32+ *
33+ * <p>Sessions are scoped by the upstream {@code hermes_profile} cookie. This client
34+ * keeps a {@link CookieManager} and calls {@code /api/profile/switch} before
35+ * session/chat calls so CLI/Slack turns see the same profile Spring provisioned
36+ * ({@code u-<user>}), matching the browser Agent tab.
3037 */
3138@ Service
3239public class AgentChatClient {
3340 private static final Logger log = LoggerFactory .getLogger (AgentChatClient .class );
3441
3542 private final HttpClient http = HttpClient .newBuilder ()
36- .connectTimeout (Duration .ofSeconds (5 )).build ();
43+ .connectTimeout (Duration .ofSeconds (5 ))
44+ .cookieHandler (new CookieManager (null , CookiePolicy .ACCEPT_ALL ))
45+ .build ();
3746 private final ObjectMapper objectMapper = new ObjectMapper ();
3847
3948 /** Internal base URL of the agent webui (compose network). */
@@ -49,11 +58,6 @@ public record AgentReply(boolean ok, String text, List<String> toolSteps, String
4958 public static AgentReply fail (String error ) { return new AgentReply (false , null , List .of (), error ); }
5059 }
5160
52- /**
53- * Return a usable session id for the given profile: reuse {@code existingSessionId}
54- * when present, otherwise create a fresh session and auto-approve its read-only
55- * tool surface (channels are non-interactive). Returns null on failure.
56- */
5761 /**
5862 * Why a session could not be created, for callers that surface a reason to the user.
5963 * {@code sessionId} non-null means success and {@code failureReason} is null.
@@ -62,11 +66,18 @@ public record SessionAttempt(String sessionId, String failureReason) {
6266 boolean ok () { return sessionId != null ; }
6367 }
6468
69+ /** Convenience wrapper around {@link #ensureSessionDetailed}. */
6570 public String ensureSession (String profile , String existingSessionId ) {
6671 return ensureSessionDetailed (profile , existingSessionId ).sessionId ();
6772 }
68-
6973 public SessionAttempt ensureSessionDetailed (String profile , String existingSessionId ) {
74+ try {
75+ switchProfile (profile );
76+ } catch (Exception e ) {
77+ log .warn ("Could not switch agent profile {} at {}: {}" , profile , webuiUrl , e .toString ());
78+ log .debug ("agent profile/switch failure detail" , e );
79+ return new SessionAttempt (null , "could not switch agent profile: " + describe (e ));
80+ }
7081 if (existingSessionId != null && !existingSessionId .isBlank ()) {
7182 return new SessionAttempt (existingSessionId , null );
7283 }
@@ -101,6 +112,13 @@ public SessionAttempt ensureSessionDetailed(String profile, String existingSessi
101112 }
102113 }
103114
115+ /** Bind subsequent requests to {@code profile} via the upstream profile cookie. */
116+ private void switchProfile (String profile ) throws Exception {
117+ if (profile == null || profile .isBlank ()) {
118+ throw new IllegalArgumentException ("agent profile is required" );
119+ }
120+ postJson ("/api/profile/switch" , Map .of ("name" , profile ));
121+ }
104122 /**
105123 * Start a turn and block until it finishes (or times out), returning the
106124 * assembled assistant text and the tool steps it ran.
@@ -116,7 +134,7 @@ public AgentReply sendAndAwait(String sessionId, String message) {
116134 return AgentReply .fail ("agent did not start a turn" );
117135 }
118136 } catch (Exception e ) {
119- return AgentReply .fail ("could not start agent turn: " + e . getMessage ( ));
137+ return AgentReply .fail ("could not start agent turn: " + describe ( e ));
120138 }
121139 return consumeStream (streamId );
122140 }
@@ -131,6 +149,7 @@ private AgentReply consumeStream(String streamId) {
131149 StringBuilder answer = new StringBuilder ();
132150 List <String > toolSteps = new ArrayList <>();
133151 boolean ended = false ;
152+ String streamError = null ;
134153 try {
135154 HttpResponse <InputStream > resp = http .send (req , HttpResponse .BodyHandlers .ofInputStream ());
136155 if (resp .statusCode () / 100 != 2 ) {
@@ -156,6 +175,13 @@ private AgentReply consumeStream(String streamId) {
156175 // after the LAST tool is the real reply.
157176 answer .setLength (0 );
158177 }
178+ case "error" , "apperror" -> {
179+ String err = textField (data , "message" );
180+ if (err == null || err .isBlank ()) err = textField (data , "error" );
181+ if (err == null || err .isBlank ()) err = data ;
182+ streamError = err ;
183+ ended = true ;
184+ }
159185 case "stream_end" , "done" -> ended = true ;
160186 default -> { /* metering, context_status, interim_assistant, title — ignore */ }
161187 }
@@ -166,8 +192,11 @@ private AgentReply consumeStream(String streamId) {
166192 } catch (Exception e ) {
167193 return AgentReply .fail ("agent stream error: " + e .getMessage ());
168194 }
195+ if (streamError != null && !streamError .isBlank ()) {
196+ return AgentReply .fail ("agent runtime error: " + streamError );
197+ }
169198 String text = answer .toString ().trim ();
170- if (text .isEmpty () && ! ended ) {
199+ if (text .isEmpty ()) {
171200 return AgentReply .fail ("agent run ended before producing an answer" );
172201 }
173202 return AgentReply .ok (text , toolSteps );
0 commit comments