2626/**
2727 * Synchronous client for the DeepSQL Agent webui API, reached over the compose
2828 * network (internal — not via the gated /agent-api proxy). Used by non-browser
29- * 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
3030 * stream: it starts a turn, consumes the stream server-side, and returns the
3131 * 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.
3237 */
3338@ Service
3439public class AgentChatClient {
@@ -62,11 +67,6 @@ public record AgentReply(boolean ok, String text, List<String> toolSteps, String
6267 public static AgentReply fail (String error ) { return new AgentReply (false , null , List .of (), error ); }
6368 }
6469
65- /**
66- * Return a usable session id for the given profile: reuse {@code existingSessionId}
67- * when present, otherwise create a fresh session and auto-approve its read-only
68- * tool surface (channels are non-interactive). Returns null on failure.
69- */
7070 /**
7171 * Why a session could not be created, for callers that surface a reason to the user.
7272 * {@code sessionId} non-null means success and {@code failureReason} is null.
@@ -75,17 +75,18 @@ public record SessionAttempt(String sessionId, String failureReason) {
7575 boolean ok () { return sessionId != null ; }
7676 }
7777
78+ /** Convenience wrapper around {@link #ensureSessionDetailed}. */
7879 public String ensureSession (String profile , String existingSessionId ) {
7980 return ensureSessionDetailed (profile , existingSessionId ).sessionId ();
8081 }
81-
8282 public SessionAttempt ensureSessionDetailed (String profile , String existingSessionId ) {
8383 try {
8484 switchProfile (profile );
8585 } catch (Exception e ) {
8686 log .warn ("Could not switch agent profile to {} (webui={}): {}" ,
8787 profile , webuiUrl , e .toString ());
88- return new SessionAttempt (null , describe (e ));
88+ log .debug ("agent profile/switch failure detail" , e );
89+ return new SessionAttempt (null , "could not switch agent profile: " + describe (e ));
8990 }
9091 if (existingSessionId != null && !existingSessionId .isBlank ()) {
9192 return new SessionAttempt (existingSessionId , null );
@@ -123,7 +124,9 @@ public SessionAttempt ensureSessionDetailed(String profile, String existingSessi
123124
124125 /** Activate the hermes_profile cookie for subsequent webui API calls. */
125126 private void switchProfile (String profile ) throws Exception {
126- if (profile == null || profile .isBlank ()) return ;
127+ if (profile == null || profile .isBlank ()) {
128+ throw new IllegalArgumentException ("agent profile is required" );
129+ }
127130 postJson ("/api/profile/switch" , Map .of ("name" , profile ));
128131 }
129132
@@ -142,7 +145,7 @@ public AgentReply sendAndAwait(String sessionId, String message) {
142145 return AgentReply .fail ("agent did not start a turn" );
143146 }
144147 } catch (Exception e ) {
145- return AgentReply .fail ("could not start agent turn: " + e . getMessage ( ));
148+ return AgentReply .fail ("could not start agent turn: " + describe ( e ));
146149 }
147150 return consumeStream (streamId );
148151 }
@@ -157,6 +160,7 @@ private AgentReply consumeStream(String streamId) {
157160 StringBuilder answer = new StringBuilder ();
158161 List <String > toolSteps = new ArrayList <>();
159162 boolean ended = false ;
163+ String streamError = null ;
160164 try {
161165 HttpResponse <InputStream > resp = http .send (req , HttpResponse .BodyHandlers .ofInputStream ());
162166 if (resp .statusCode () / 100 != 2 ) {
@@ -182,6 +186,13 @@ private AgentReply consumeStream(String streamId) {
182186 // after the LAST tool is the real reply.
183187 answer .setLength (0 );
184188 }
189+ case "error" , "apperror" -> {
190+ String err = textField (data , "message" );
191+ if (err == null || err .isBlank ()) err = textField (data , "error" );
192+ if (err == null || err .isBlank ()) err = data ;
193+ streamError = err ;
194+ ended = true ;
195+ }
185196 case "stream_end" , "done" -> ended = true ;
186197 default -> { /* metering, context_status, interim_assistant, title — ignore */ }
187198 }
@@ -192,8 +203,11 @@ private AgentReply consumeStream(String streamId) {
192203 } catch (Exception e ) {
193204 return AgentReply .fail ("agent stream error: " + e .getMessage ());
194205 }
206+ if (streamError != null && !streamError .isBlank ()) {
207+ return AgentReply .fail ("agent runtime error: " + streamError );
208+ }
195209 String text = answer .toString ().trim ();
196- if (text .isEmpty () && ! ended ) {
210+ if (text .isEmpty ()) {
197211 return AgentReply .fail ("agent run ended before producing an answer" );
198212 }
199213 return AgentReply .ok (text , toolSteps );
0 commit comments