Skip to content

Commit b88567c

Browse files
authored
Merge pull request #413 from editor-code-assistant/improve-tool-schema-required-ordering
Surface required tool params first for models
2 parents 81c76ae + 106f4b8 commit b88567c

File tree

7 files changed

+154
-9
lines changed

7 files changed

+154
-9
lines changed

src/eca/features/chat/tool_calls.clj

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
[eca.features.hooks :as f.hooks]
66
[eca.features.tools :as f.tools]
77
[eca.features.tools.mcp :as f.tools.mcp]
8+
[eca.features.tools.util :as tools.util]
89
[eca.llm-util :as llm-util]
910
[eca.logger :as logger]
1011
[eca.shared :as shared :refer [assoc-some]]))
@@ -628,8 +629,12 @@
628629
(let [rejected-tool-call-info* (atom nil)]
629630
(run! (fn do-tool-call [{:keys [id full-name] :as tool-call}]
630631
(let [approved?* (promise)
631-
{:keys [origin name server]} (tool-by-full-name full-name all-tools)
632+
{:keys [origin name server parameters]} (tool-by-full-name full-name all-tools)
632633
server-name (:name server)
634+
tool-call (update tool-call :arguments
635+
#(if parameters
636+
(tools.util/omit-optional-empty-string-args parameters %)
637+
%))
633638
decision-plan (decide-tool-call-action
634639
tool-call all-tools @db* config agent chat-id
635640
{:on-before-hook-action (partial lifecycle/notify-before-hook-action! chat-ctx)

src/eca/features/tools.clj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@
189189
all-tools (->> (concat
190190
(mapv #(assoc % :origin :native) (native-tools db config))
191191
(mapv #(assoc % :origin :mcp) (f.mcp/all-tools db)))
192+
(mapv #(update % :parameters tools.util/reorder-schema-required-first))
192193
(mapv #(assoc % :full-name (str (-> % :server :name) "__" (:name %))))
193194
(mapv (fn [tool]
194195
(update tool :description
@@ -222,6 +223,9 @@
222223
db @db*
223224
all-tools (all-tools chat-id agent-name db config)
224225
tool-meta (some #(when (= full-name (:full-name %)) %) all-tools)
226+
arguments (if-let [parameters (:parameters tool-meta)]
227+
(tools.util/omit-optional-empty-string-args parameters arguments)
228+
arguments)
225229
required-args-error (when-let [parameters (:parameters tool-meta)]
226230
(tools.util/required-params-error parameters arguments))]
227231
(try

src/eca/features/tools/agent.clj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@
150150
;; Create subagent chat session using deterministic id based on tool-call-id
151151
subagent-chat-id (->subagent-chat-id tool-call-id)
152152

153-
user-model (tools.util/normalize-optional-string (get arguments "model"))
153+
user-model (get arguments "model")
154154
_ (when user-model
155155
(let [available-models (:models db)]
156156
(when (and (seq available-models)
@@ -167,7 +167,7 @@
167167
;; Variant validation: reject only when the resolved model has configured
168168
;; variants and the user-specified one isn't among them. Models with no
169169
;; configured variants accept any variant (the LLM API will reject if invalid).
170-
user-variant (tools.util/normalize-optional-string (get arguments "variant"))
170+
user-variant (get arguments "variant")
171171
_ (when user-variant
172172
(let [valid-variants (model-variant-names config subagent-model)]
173173
(when (and (seq valid-variants)
@@ -296,8 +296,8 @@
296296
(defmethod tools.util/tool-call-details-before-invocation :spawn_agent
297297
[_name arguments _server {:keys [db config chat-id tool-call-id]}]
298298
(let [agent-name (get arguments "agent")
299-
user-model (tools.util/normalize-optional-string (get arguments "model"))
300-
user-variant (tools.util/normalize-optional-string (get arguments "variant"))
299+
user-model (get arguments "model")
300+
user-variant (get arguments "variant")
301301
subagent (when agent-name
302302
(get-agent agent-name config))
303303
parent-model (get-in db [:chats chat-id :model])

src/eca/features/tools/util.clj

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@
109109
(-> (io/resource (str "prompts/tools/" tool-name ".md"))
110110
(slurp)))
111111

112+
(defn reorder-schema-required-first
113+
"Returns schema as an ordered map with `type` first, `required` second when
114+
present, and the remaining entries in their existing iteration order."
115+
[{:keys [type required] :as schema}]
116+
(let [first-entries (cond-> []
117+
type (conj [:type type])
118+
required (conj [:required required]))]
119+
(if (seq first-entries)
120+
(into (apply array-map (mapcat identity first-entries))
121+
(remove (fn [[k _]] (contains? #{:type :required} k)) schema))
122+
schema)))
123+
112124
(defn required-params-error
113125
"Given a tool `parameters` JSON schema (object) and an args map, return a
114126
single-text-content error when any required parameter is missing. Returns nil
@@ -123,6 +135,19 @@
123135
(->> missing (map #(str "`" % "`")) (string/join ", ")))
124136
:error)))))
125137

138+
(defn omit-optional-empty-string-args
139+
"Drops optional tool arguments whose value is the empty string.
140+
Required arguments are preserved exactly as provided."
141+
[parameters args]
142+
(let [required (->> (:required parameters)
143+
(map name)
144+
set)]
145+
(into {}
146+
(remove (fn [[k v]]
147+
(and (= "" v)
148+
(not (contains? required (name k))))))
149+
args)))
150+
126151
(defn ^:private contents->text
127152
"Concatenates all text contents from a tool result's :contents into a single string."
128153
[contents]
@@ -202,7 +227,3 @@
202227
(assoc result :contents [{:type :text
203228
:text (str truncated notice)}]))
204229
result)))))
205-
206-
(defn normalize-optional-string
207-
[value]
208-
(some-> value string/trim not-empty))

test/eca/features/tools/util_test.clj

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,40 @@
148148
config (config-with-truncation 5 10)]
149149
(is (= result (tools.util/maybe-truncate-output result config "call-empty"))))))
150150

151+
(deftest reorder-schema-required-first-test
152+
(testing "moves type first and required second when present"
153+
(is (= [[:type "object"]
154+
[:required ["path"]]
155+
[:properties {"path" {:type "string"}}]]
156+
(seq (tools.util/reorder-schema-required-first
157+
{:type "object"
158+
:properties {"path" {:type "string"}}
159+
:required ["path"]}))))))
160+
161+
(deftest omit-optional-empty-string-args-test
162+
(testing "drops optional empty string arguments"
163+
(is (= {"path" "/tmp/file"}
164+
(tools.util/omit-optional-empty-string-args
165+
{:required ["path"]}
166+
{"path" "/tmp/file"
167+
"pattern" ""}))))
168+
169+
(testing "preserves required empty string arguments"
170+
(is (= {"path" ""}
171+
(tools.util/omit-optional-empty-string-args
172+
{:required ["path"]}
173+
{"path" ""}))))
174+
175+
(testing "preserves non-string empty values"
176+
(is (= {"enabled" false
177+
"count" 0
178+
"tags" []}
179+
(tools.util/omit-optional-empty-string-args
180+
{:required []}
181+
{"enabled" false
182+
"count" 0
183+
"tags" []})))))
184+
151185
(deftest path-outside-workspace-allows-tool-call-outputs-dir-test
152186
(testing "path inside tool-call-outputs cache dir is not considered outside workspace"
153187
(let [db {:workspace-folders [{:uri (h/file-uri "file:///home/user/project") :name "project"}]}

test/eca/features/tools_test.clj

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,3 +435,67 @@
435435
identity
436436
identity
437437
nil))))))
438+
439+
(deftest call-tool!-omits-optional-empty-string-args-test
440+
(testing "optional empty string args are omitted before native tool invocation"
441+
(let [received-args* (atom nil)]
442+
(is (match?
443+
{:error false
444+
:contents [{:type :text :text "OK"}]}
445+
(with-redefs [f.tools.filesystem/definitions
446+
{"test_optional_empty"
447+
{:description "Test tool optional empty"
448+
:parameters {"type" "object"
449+
:properties {"path" {:type "string"}
450+
"pattern" {:type "string"}}
451+
:required ["path"]}
452+
:handler (fn [args _]
453+
(reset! received-args* args)
454+
{:error false
455+
:contents [{:type :text :text "OK"}]})}}]
456+
(f.tools/call-tool!
457+
"eca__test_optional_empty"
458+
{"path" "/tmp/file"
459+
"pattern" ""}
460+
"chat-3"
461+
"call-4"
462+
"code"
463+
(h/db*)
464+
(h/config)
465+
(h/messenger)
466+
(h/metrics)
467+
identity
468+
identity
469+
nil))))
470+
(is (= {"path" "/tmp/file"} @received-args*)))))
471+
472+
(deftest call-tool!-preserves-required-empty-string-args-test
473+
(testing "required empty string args are preserved"
474+
(let [received-args* (atom nil)]
475+
(is (match?
476+
{:error false
477+
:contents [{:type :text :text "OK"}]}
478+
(with-redefs [f.tools.filesystem/definitions
479+
{"test_required_empty"
480+
{:description "Test tool required empty"
481+
:parameters {"type" "object"
482+
:properties {"path" {:type "string"}}
483+
:required ["path"]}
484+
:handler (fn [args _]
485+
(reset! received-args* args)
486+
{:error false
487+
:contents [{:type :text :text "OK"}]})}}]
488+
(f.tools/call-tool!
489+
"eca__test_required_empty"
490+
{"path" ""}
491+
"chat-4"
492+
"call-5"
493+
"code"
494+
(h/db*)
495+
(h/config)
496+
(h/messenger)
497+
(h/metrics)
498+
identity
499+
identity
500+
nil))))
501+
(is (= {"path" ""} @received-args*)))))

test/eca/llm_providers/openai_chat_test.clj

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
(:require
33
[clojure.test :refer [deftest is testing]]
44
[eca.client-test-helpers :refer [with-client-proxied]]
5+
[eca.features.tools.util :as tools.util]
56
[eca.llm-providers.openai-chat :as llm-providers.openai-chat]
67
[matcher-combinators.test :refer [match?]]))
78

@@ -124,6 +125,22 @@
124125
:properties {:location {:type "string"}}}
125126
:other-field "ignored"}]))))
126127

128+
(testing "preserves normalized schema order in emitted parameters"
129+
(is (= [[:type "object"]
130+
[:required ["location"]]
131+
[:properties {:location {:type "string"}}]]
132+
(-> (#'llm-providers.openai-chat/->tools
133+
[{:full-name "eca__get_weather"
134+
:description "Get the weather"
135+
:parameters (tools.util/reorder-schema-required-first
136+
{:required ["location"]
137+
:type "object"
138+
:properties {:location {:type "string"}}})}])
139+
first
140+
:function
141+
:parameters
142+
seq))))
143+
127144
(testing "Empty tools list"
128145
(is (match?
129146
[]

0 commit comments

Comments
 (0)