From 5c7122d14201434a3a0cc4b747efd68cc4252159 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 05:08:58 +0000 Subject: [PATCH] fix(agent): make CLI agent turns work via profile cookie + provisioner recovery AgentChatClient now switches hermes_profile (CookieManager) before session/chat calls so CLI/Slack match the browser Agent tab. Surface SSE error events instead of empty answers. Local provisioner recovers corrupt profile config.yaml (which dropped model: and caused Missed model deployment) and validates dumps. Co-authored-by: Venkat SF --- AGENTS.md | 19 +++++++++- .../com/dbaagent/service/AgentChatClient.java | 36 +++++++++++++------ scripts/local-agent-provisioner.py | 34 ++++++++++++++++-- 3 files changed, 74 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ff7edf1..342b4e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,7 +235,24 @@ only covers cloud-specific, non-obvious caveats. `http://deepsql-agent:8788/provision`) with `AGENT_PROVISION_SECRET`. In this VM run `python3 scripts/local-agent-provisioner.py` (needs those two env vars in `.env`). Without it, Spring logs `agent.provision-secret is unset — skipping…` and the - `u-admin` agent profile is never created/token-refreshed. + `u-admin` agent profile is never created/token-refreshed. If provision returns HTTP + 500 with a PyYAML parse error on `config.yaml`, the profile is corrupt (often a + mangled `agent.personalities` block that also drops `model:`) — the provisioner + now restores from `~/.hermes/config.yaml`. Manual recovery: copy that default + over `~/.hermes/profiles/u-/config.yaml` and re-POST `/provision`. Symptom + of a bad profile: Hermes logs `Missed model deployment` and CLI agent returns + empty / “ended before producing an answer”. +- **`AGENT_WEBUI_URL` for native runs.** Default is `http://deepsql-agent:8787` + (Compose DNS). Native local must set `AGENT_WEBUI_URL=http://127.0.0.1:8787` in + `.env` or CLI/Slack `AgentChatClient` cannot reach the agent API. +- **DeepSQL CLI (`deepsql`) for agent testing.** Install from the repo package: + `cd mcp && DEEPSQL_SKIP_AGENT_SETUP=1 npm install -g .` (prefix + `~/.npm-global`, keep that on `PATH`). Auth against local backend with an MCP + token (`POST /api/auth/mcp-tokens` when auth is disabled stores into + `~/.config/deepsql/auth.json`). One-shot: + `deepsql agent --connection "…"`. Interactive: `deepsql` / `deepsql agent`. + The CLI is a thin client over `POST /api/agent/chat` (not a local agent runtime); + backend + agent API (:8787) + provisioner must already be up. - **Spring CORS must allow both loopback hosts.** Set `CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000` in `.env`. Opening the UI as `http://127.0.0.1:3000` while only `localhost` is allowlisted yields **403** diff --git a/backend/src/main/java/com/dbaagent/service/AgentChatClient.java b/backend/src/main/java/com/dbaagent/service/AgentChatClient.java index 499d2ad..cf65cc6 100644 --- a/backend/src/main/java/com/dbaagent/service/AgentChatClient.java +++ b/backend/src/main/java/com/dbaagent/service/AgentChatClient.java @@ -26,9 +26,14 @@ /** * Synchronous client for the DeepSQL Agent webui API, reached over the compose * network (internal — not via the gated /agent-api proxy). Used by non-browser - * channels (Slack, etc.) that need a single final answer rather than a live SSE + * channels (Slack, CLI, etc.) that need a single final answer rather than a live SSE * stream: it starts a turn, consumes the stream server-side, and returns the * assembled text plus a compact tool-step summary. + * + *

Sessions are scoped by the upstream {@code hermes_profile} cookie. This client + * keeps a {@link CookieManager} and calls {@code /api/profile/switch} before + * session/chat calls so CLI/Slack turns see the same profile Spring provisioned + * ({@code u-}), matching the browser Agent tab. */ @Service public class AgentChatClient { @@ -62,11 +67,6 @@ public record AgentReply(boolean ok, String text, List toolSteps, String public static AgentReply fail(String error) { return new AgentReply(false, null, List.of(), error); } } - /** - * Return a usable session id for the given profile: reuse {@code existingSessionId} - * when present, otherwise create a fresh session and auto-approve its read-only - * tool surface (channels are non-interactive). Returns null on failure. - */ /** * Why a session could not be created, for callers that surface a reason to the user. * {@code sessionId} non-null means success and {@code failureReason} is null. @@ -75,17 +75,18 @@ public record SessionAttempt(String sessionId, String failureReason) { boolean ok() { return sessionId != null; } } + /** Convenience wrapper around {@link #ensureSessionDetailed}. */ public String ensureSession(String profile, String existingSessionId) { return ensureSessionDetailed(profile, existingSessionId).sessionId(); } - public SessionAttempt ensureSessionDetailed(String profile, String existingSessionId) { try { switchProfile(profile); } catch (Exception e) { log.warn("Could not switch agent profile to {} (webui={}): {}", profile, webuiUrl, e.toString()); - return new SessionAttempt(null, describe(e)); + log.debug("agent profile/switch failure detail", e); + return new SessionAttempt(null, "could not switch agent profile: " + describe(e)); } if (existingSessionId != null && !existingSessionId.isBlank()) { return new SessionAttempt(existingSessionId, null); @@ -123,7 +124,9 @@ public SessionAttempt ensureSessionDetailed(String profile, String existingSessi /** Activate the hermes_profile cookie for subsequent webui API calls. */ private void switchProfile(String profile) throws Exception { - if (profile == null || profile.isBlank()) return; + if (profile == null || profile.isBlank()) { + throw new IllegalArgumentException("agent profile is required"); + } postJson("/api/profile/switch", Map.of("name", profile)); } @@ -142,7 +145,7 @@ public AgentReply sendAndAwait(String sessionId, String message) { return AgentReply.fail("agent did not start a turn"); } } catch (Exception e) { - return AgentReply.fail("could not start agent turn: " + e.getMessage()); + return AgentReply.fail("could not start agent turn: " + describe(e)); } return consumeStream(streamId); } @@ -157,6 +160,7 @@ private AgentReply consumeStream(String streamId) { StringBuilder answer = new StringBuilder(); List toolSteps = new ArrayList<>(); boolean ended = false; + String streamError = null; try { HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream()); if (resp.statusCode() / 100 != 2) { @@ -182,6 +186,13 @@ private AgentReply consumeStream(String streamId) { // after the LAST tool is the real reply. answer.setLength(0); } + case "error", "apperror" -> { + String err = textField(data, "message"); + if (err == null || err.isBlank()) err = textField(data, "error"); + if (err == null || err.isBlank()) err = data; + streamError = err; + ended = true; + } case "stream_end", "done" -> ended = true; default -> { /* metering, context_status, interim_assistant, title — ignore */ } } @@ -192,8 +203,11 @@ private AgentReply consumeStream(String streamId) { } catch (Exception e) { return AgentReply.fail("agent stream error: " + e.getMessage()); } + if (streamError != null && !streamError.isBlank()) { + return AgentReply.fail("agent runtime error: " + streamError); + } String text = answer.toString().trim(); - if (text.isEmpty() && !ended) { + if (text.isEmpty()) { return AgentReply.fail("agent run ended before producing an answer"); } return AgentReply.ok(text, toolSteps); diff --git a/scripts/local-agent-provisioner.py b/scripts/local-agent-provisioner.py index c7e74ce..57bf8d9 100755 --- a/scripts/local-agent-provisioner.py +++ b/scripts/local-agent-provisioner.py @@ -74,12 +74,37 @@ def write_profile_env(home: Path, *, user: str, token: str) -> None: os.chmod(env_path, 0o600) +def _load_profile_config(home: Path): + """Load profile config.yaml, recovering from a corrupt file by cloning the default. + + A prior dump race (or a partial write) can leave personas/model keys mangled so + PyYAML refuses to parse. Without recovery, every subsequent /provision 500s and + the agent profile never gets a fresh MCP token — which surfaces to CLI users as + empty agent turns, not as a clear provisioner error. + """ + import yaml # agent venv / system PyYAML + + cfg_path = home / "config.yaml" + default_path = HERMES_HOME / "config.yaml" + if cfg_path.exists(): + try: + cfg = yaml.safe_load(cfg_path.read_text()) + if isinstance(cfg, dict) and cfg.get("model"): + return cfg + except Exception as e: + sys.stderr.write(f"[agent-provisioner] corrupt {cfg_path}: {e}; restoring from default\n") + if default_path.exists(): + cfg = yaml.safe_load(default_path.read_text()) or {} + else: + cfg = {} + return cfg if isinstance(cfg, dict) else {} + + def write_profile_mcp(home: Path, *, user: str, token: str) -> None: import yaml # agent venv / system PyYAML cfg_path = home / "config.yaml" - cfg = yaml.safe_load(cfg_path.read_text()) if cfg_path.exists() else {} - cfg = cfg or {} + cfg = _load_profile_config(home) # Token must live on the MCP subprocess env — the agent runtime does not auto-forward # the profile .env into mcp_servers.*.env. cfg.setdefault("mcp_servers", {})["deepsql"] = { @@ -94,7 +119,10 @@ def write_profile_mcp(home: Path, *, user: str, token: str) -> None: } cfg.setdefault("skills", {})["external_dirs"] = [str(REPO_ROOT / "agent" / "skills")] cfg.setdefault("approvals", {})["mode"] = "smart" - cfg_path.write_text(yaml.safe_dump(cfg, sort_keys=False)) + dumped = yaml.safe_dump(cfg, sort_keys=False, allow_unicode=True) + # Refuse to write unparseable YAML — better a loud 500 than a silent corrupt profile. + yaml.safe_load(dumped) + cfg_path.write_text(dumped) soul_src = REPO_ROOT / "agent" / "SOUL.md" if soul_src.exists(): (home / "SOUL.md").write_text(soul_src.read_text())