Skip to content

Commit be7de2e

Browse files
authored
Merge branch 'master' into fix-mcp-routing-openai-strict
2 parents f26c5fe + 84d3985 commit be7de2e

13 files changed

Lines changed: 317 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@
55
- Bugfix: OpenAI Responses tool calls now opt out of strict schema normalization so optional tool parameters remain optional.
66
- Bugfix: MCP tool calls now route to the selected server when multiple servers expose the same tool name.
77

8+
## 0.133.6
9+
10+
- Bugfix: `network.caCertFile` (and `clientCert`/`clientKey`/`clientKeyPassphrase`) set via `config.json` were silently ignored due to a key-case mismatch between config normalization and the network reader; only the env-var fallbacks worked. #457
11+
12+
## 0.133.5
13+
14+
- Improve CPU usage while streaming tool-call arguments by reusing the prompt's tool list.
15+
- Improve connection error messages from LLM providers. #457
16+
17+
## 0.133.4
18+
19+
- Bugfix: stop the infinite "Cannot run program 'kill'" liveness-probe log loop for sandboxed environments.
20+
821
## 0.133.3
922

1023
- Add unit and integration tests covering parent↔subagent end-to-end communication so regressions like the v0.133.1 spawn-agent breakage are caught automatically.

resources/ECA_VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.133.3
1+
0.133.6

src/eca/features/chat.clj

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -828,8 +828,7 @@
828828
(lifecycle/finish-chat-prompt! :idle chat-ctx)))))
829829
:on-prepare-tool-call (fn [{:keys [id full-name arguments-text]}]
830830
(lifecycle/assert-chat-not-stopped! chat-ctx)
831-
(let [all-tools (f.tools/all-tools chat-id agent @db* config)
832-
tool (f.tools/resolve-tool full-name all-tools)
831+
(let [tool (f.tools/resolve-tool full-name all-tools)
833832
resolved-full-name (or (:full-name tool) full-name)]
834833
(when-not tool
835834
(logger/warn logger-tag "Tool not found for prepare"

src/eca/llm_providers/anthropic.clj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@
161161

162162
:else
163163
(on-error {:exception e
164-
:message (format "Connection error: %s" (or (ex-message e) (.getName (class e))))}))))
164+
:message (llm-util/connection-error-message e)}))))
165165
(finally
166166
(stop-fn))))
167167
(do
@@ -170,7 +170,7 @@
170170
{:output-text (:text (last (:content body)))})))))
171171
(catch Exception e
172172
(on-error {:exception e
173-
:message (format "Connection error: %s" (or (ex-message e) (.getName (class e))))})))
173+
:message (llm-util/connection-error-message e)})))
174174
@response*))
175175

176176
(defn ^:private normalize-messages [past-messages supports-image?]

src/eca/llm_providers/ollama.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
{:output-text (:content (:message body))})))))
9191
(catch Exception e
9292
(on-error {:exception e
93-
:message (format "Connection error: %s" (or (ex-message e) (.getName (class e))))})))
93+
:message (llm-util/connection-error-message e)})))
9494
@response*))
9595

9696
(defn ^:private ->tools [tools]

src/eca/llm_providers/openai.clj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,10 @@
9696
(llm-util/log-response logger-tag rid "response" body)
9797
(response-body->result body)))))
9898
(catch Exception e
99-
(let [msg (or (ex-message e) (.getName (class e)))
100-
prefix (if (ex-data e) "Internal error" "Connection error")]
101-
(on-error {:exception e
102-
:message (format "%s: %s" prefix msg)}))))))
99+
(on-error {:exception e
100+
:message (if (ex-data e)
101+
(format "Internal error: %s" (or (ex-message e) (.getName (class e))))
102+
(llm-util/connection-error-message e))})))))
103103

104104
(defn ^:private normalize-messages [messages supports-image?]
105105
;; Each history entry maps to one or more provider messages. Switched from

src/eca/llm_providers/openai_chat.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@
154154
(response-body->result body on-tools-called-wrapper)))))
155155
(catch Exception e
156156
(on-error {:exception e
157-
:message (format "Connection error: %s" (or (ex-message e) (.getName (class e))))})))))
157+
:message (llm-util/connection-error-message e)})))))
158158

159159
(defn ^:private transform-message
160160
"Transform a single ECA message to OpenAI format. Returns nil for unsupported roles.

src/eca/llm_util.clj

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
[eca.secrets :as secrets]
99
[eca.shared :as shared])
1010
(:import
11-
[java.io BufferedReader Closeable]))
11+
[java.io BufferedReader Closeable]
12+
[java.net ConnectException SocketTimeoutException UnknownHostException]
13+
[java.net.http HttpConnectTimeoutException]
14+
[javax.net.ssl SSLException]))
1215

1316
(set! *warn-on-reflection* true)
1417

@@ -156,3 +159,76 @@
156159
(some-> (get-in config [:providers (name provider) :urlEnv]) config/get-env)) ;; legacy
157160
shared/normalize-api-url
158161
not-empty))
162+
163+
(defn ^:private cause-chain
164+
"Returns a seq of `e` followed by every nested cause."
165+
[^Throwable e]
166+
(->> (iterate (fn [^Throwable t] (.getCause t)) e)
167+
(take-while some?)))
168+
169+
(defn ^:private root-message [^Throwable e]
170+
(or (ex-message e) (.getName (class e))))
171+
172+
(defn classify-connection-exception
173+
"Walks the cause chain of `e` and classifies common HTTP/TLS failures
174+
into a user-friendly map: {:kind <keyword> :message <string>}.
175+
176+
Recognized kinds:
177+
- :tls-untrusted - PKIX path building failed (private/corporate CA not trusted)
178+
- :tls-other - other TLS/SSL handshake errors
179+
- :dns - UnknownHostException
180+
- :connect-refused - ConnectException (connection refused, etc.)
181+
- :timeout - connection/socket timeouts
182+
- :unknown - fallback; keeps the historical 'Connection error: ...' format"
183+
[^Throwable e]
184+
(let [msg (root-message e)
185+
causes (cause-chain e)
186+
pkix? (some (fn [^Throwable c]
187+
(some-> (ex-message c)
188+
(string/includes? "PKIX path building failed")))
189+
causes)
190+
ssl? (some #(instance? SSLException %) causes)
191+
dns? (some #(instance? UnknownHostException %) causes)
192+
connect-refused? (some #(instance? ConnectException %) causes)
193+
timeout? (some #(or (instance? HttpConnectTimeoutException %)
194+
(instance? SocketTimeoutException %))
195+
causes)]
196+
(cond
197+
pkix?
198+
{:kind :tls-untrusted
199+
:message (str "TLS certificate not trusted: PKIX path building failed. "
200+
"The server's certificate is signed by a CA not in the JVM truststore "
201+
"(common with private/corporate CAs). "
202+
"Fix: set `network.caCertFile` in your ECA config or the `SSL_CERT_FILE` "
203+
"env var to a PEM bundle containing the missing CA. "
204+
"See docs/config/network.md for details. Original error: " msg)}
205+
206+
ssl?
207+
{:kind :tls-other
208+
:message (str "TLS error: " msg
209+
". See docs/config/network.md for trust and mTLS configuration.")}
210+
211+
dns?
212+
{:kind :dns
213+
:message (str "DNS resolution failed: " msg
214+
". Check the provider URL and your network/proxy settings.")}
215+
216+
connect-refused?
217+
{:kind :connect-refused
218+
:message (str "Could not connect: " msg
219+
". Check the provider URL and whether the server is reachable. "
220+
"Corporate networks may require HTTP_PROXY / HTTPS_PROXY env vars.")}
221+
222+
timeout?
223+
{:kind :timeout
224+
:message (str "Connection timed out: " msg ".")}
225+
226+
:else
227+
{:kind :unknown
228+
:message (format "Connection error: %s" msg)})))
229+
230+
(defn connection-error-message
231+
"Returns a user-friendly message describing a connection-level exception.
232+
Always non-nil. See `classify-connection-exception` for recognized error kinds."
233+
[^Throwable e]
234+
(:message (classify-connection-exception e)))

src/eca/network.clj

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,15 @@
112112
"Reads network TLS configuration from the config `network` section
113113
(when available) and falls back to well-known environment variables.
114114
115-
Config values (camelCase as from JSON):
116-
:caCertFile - path to a PEM CA certificate bundle
117-
:clientCert - path to a PEM client certificate for mTLS
118-
:clientKey - path to a PEM client private key for mTLS
119-
:clientKeyPassphrase - passphrase for an encrypted client key
115+
The JSON config uses camelCase (`caCertFile`, `clientCert`, ...), but
116+
`eca.config` normalizes keys under `:network` to kebab-case before
117+
this function is called, so we look up kebab-cased keys here.
118+
119+
Config values (kebab-case after normalization):
120+
:ca-cert-file - path to a PEM CA certificate bundle
121+
:client-cert - path to a PEM client certificate for mTLS
122+
:client-key - path to a PEM client private key for mTLS
123+
:client-key-passphrase - passphrase for an encrypted client key
120124
121125
Environment variable fallbacks (lowest priority):
122126
SSL_CERT_FILE / NODE_EXTRA_CA_CERTS -> :ca-cert-file
@@ -125,14 +129,14 @@
125129
ECA_CLIENT_KEY_PASSPHRASE -> :client-key-passphrase"
126130
[file-config]
127131
(let [net (:network file-config)]
128-
{:ca-cert-file (or (non-blank (:caCertFile net))
132+
{:ca-cert-file (or (non-blank (:ca-cert-file net))
129133
(non-blank (config/get-env "SSL_CERT_FILE"))
130134
(non-blank (config/get-env "NODE_EXTRA_CA_CERTS")))
131-
:client-cert (or (non-blank (:clientCert net))
135+
:client-cert (or (non-blank (:client-cert net))
132136
(non-blank (config/get-env "ECA_CLIENT_CERT")))
133-
:client-key (or (non-blank (:clientKey net))
137+
:client-key (or (non-blank (:client-key net))
134138
(non-blank (config/get-env "ECA_CLIENT_KEY")))
135-
:client-key-passphrase (or (non-blank (:clientKeyPassphrase net))
139+
:client-key-passphrase (or (non-blank (:client-key-passphrase net))
136140
(non-blank (config/get-env "ECA_CLIENT_KEY_PASSPHRASE")))}))
137141

138142
(defn load-pem-certificates
@@ -279,13 +283,20 @@
279283
custom CA or mTLS settings are present, and stores it in
280284
`*ssl-context*`."
281285
[file-config]
282-
(let [net-cfg (read-network-config file-config)]
286+
(let [net-cfg (read-network-config file-config)
287+
configured? (boolean (:network file-config))]
288+
(logger/debug logger-tag "Resolved network config:" net-cfg)
283289
(try
284-
(when-let [ctx (build-ssl-context net-cfg)]
285-
(logger/info logger-tag "Custom SSL context configured"
286-
(cond-> {}
287-
(:ca-cert-file net-cfg) (assoc :ca-cert-file (:ca-cert-file net-cfg))
288-
(:client-cert net-cfg) (assoc :client-cert (:client-cert net-cfg))))
289-
(alter-var-root #'*ssl-context* (constantly ctx)))
290+
(if-let [ctx (build-ssl-context net-cfg)]
291+
(do
292+
(logger/info logger-tag "Custom SSL context configured"
293+
(cond-> {}
294+
(:ca-cert-file net-cfg) (assoc :ca-cert-file (:ca-cert-file net-cfg))
295+
(:client-cert net-cfg) (assoc :client-cert (:client-cert net-cfg))))
296+
(alter-var-root #'*ssl-context* (constantly ctx)))
297+
(when configured?
298+
(logger/warn logger-tag
299+
(str "`network` config present but no TLS settings were resolved; "
300+
"using JVM defaults. Check caCertFile/clientCert/clientKey paths."))))
290301
(catch Exception e
291302
(logger/error logger-tag "Failed to build SSL context:" (.getMessage e))))))

src/eca/server.clj

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@
1313
[eca.remote.server :as remote.server]
1414
[eca.shared :as shared :refer [assoc-some]]
1515
[jsonrpc4clj.io-server :as io-server]
16-
[jsonrpc4clj.liveness-probe :as liveness-probe]
1716
[jsonrpc4clj.server :as jsonrpc.server]
18-
[promesa.core :as p]))
17+
[promesa.core :as p])
18+
(:import
19+
[java.lang ProcessHandle]
20+
[java.util Optional]
21+
[java.util.concurrent CompletableFuture]
22+
[java.util.function BiConsumer]))
1923

2024
(set! *warn-on-reflection* true)
2125

@@ -56,9 +60,39 @@
5660
(catch Throwable e#
5761
(logger/error e# "[server] Error in async notification handler")))))
5862

63+
(defn ^:private start-liveness-probe!
64+
"Monitor parent process `ppid`; invoke `on-exit` once when the parent
65+
disappears. Event-driven via `ProcessHandle.onExit` so we don't shell out
66+
to `kill -0` (which loops forever when the binary is missing)."
67+
[ppid on-exit]
68+
(let [fire! (fn []
69+
(try (on-exit)
70+
(catch Throwable t
71+
(logger/error t "[server] Liveness probe - on-exit threw"))))]
72+
(try
73+
(let [opt ^Optional (ProcessHandle/of (long ppid))]
74+
(if (.isPresent opt)
75+
(let [^ProcessHandle handle (.get opt)
76+
^CompletableFuture fut (.onExit handle)]
77+
(.whenComplete fut
78+
(reify BiConsumer
79+
(accept [_ _result ex]
80+
(if (some? ex)
81+
(logger/warn ex "[server] Liveness probe - failed waiting for parent" ppid "- monitor disabled")
82+
(do
83+
(logger/info "[server] Liveness probe - parent" ppid "exited - exiting server")
84+
(fire!))))))
85+
nil)
86+
(do
87+
(logger/info "[server] Liveness probe - parent" ppid "is not running - exiting server")
88+
(fire!))))
89+
(catch Throwable t
90+
(logger/error t "[server] Liveness probe - failed to start; parent monitoring disabled")
91+
nil))))
92+
5993
(defmethod jsonrpc.server/receive-request "initialize" [_ {:keys [server] :as components} params]
6094
(when-let [parent-process-id (:process-id params)]
61-
(liveness-probe/start! parent-process-id log-wrapper-fn #(exit server)))
95+
(start-liveness-probe! parent-process-id #(exit server)))
6296
(handlers/initialize components params))
6397

6498
(defmethod jsonrpc.server/receive-notification "initialized" [_ components _params]

0 commit comments

Comments
 (0)