Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<user>/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 <uuid> "…"`. 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**
Expand Down
36 changes: 25 additions & 11 deletions backend/src/main/java/com/dbaagent/service/AgentChatClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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-<user>}), matching the browser Agent tab.
*/
@Service
public class AgentChatClient {
Expand Down Expand Up @@ -62,11 +67,6 @@ public record AgentReply(boolean ok, String text, List<String> 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.
Expand All @@ -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);
Expand Down Expand Up @@ -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));
}

Expand All @@ -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);
}
Expand All @@ -157,6 +160,7 @@ private AgentReply consumeStream(String streamId) {
StringBuilder answer = new StringBuilder();
List<String> toolSteps = new ArrayList<>();
boolean ended = false;
String streamError = null;
try {
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
if (resp.statusCode() / 100 != 2) {
Expand All @@ -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 */ }
}
Expand All @@ -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);
Expand Down
34 changes: 31 additions & 3 deletions scripts/local-agent-provisioner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] = {
Expand All @@ -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())
Expand Down
Loading