diff --git a/CHANGELOG.md b/CHANGELOG.md index 66fe2b5..c4812e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. This change ## [Unreleased] +### Added (post-v1.0.1 sync) +- **`:defer` tool-definition option** — port of upstream + [PR #1632](https://github.com/github/copilot-sdk/pull/1632). `define-tool` and + `define-tool-from-spec` now accept an optional `:defer` keyword (`:auto` or + `:never`) that controls whether a tool may be deferred (loaded lazily via tool + search) rather than always pre-loaded. The keyword is converted to the wire + string (`"auto"` / `"never"`) and sent on the tool definition in both + `session.create` and `session.resume`; when omitted the field is not sent and + the runtime applies its default (`"auto"`). Added `::defer` value spec + (`#{:auto :never}`) to the `::tool` spec. + ### Added (v1.0.1 sync) - **`open-canvases` snapshot** — port of upstream PR #1604. A new `github.copilot-sdk/open-canvases` (also `github.copilot-sdk.session/open-canvases`) diff --git a/doc/reference/API.md b/doc/reference/API.md index 6074cae..c042cf4 100644 --- a/doc/reference/API.md +++ b/doc/reference/API.md @@ -1842,6 +1842,21 @@ Set `:overrides-built-in-tool true` to override a built-in tool (e.g., `grep`, ` (copilot/result-success (my-custom-grep pattern)))})) ``` +**Deferring tools:** + +Set `:defer` to `:auto` or `:never` (upstream PR #1632) to control whether a tool may be *deferred* — loaded lazily via tool search rather than always pre-loaded into the model's context. `:auto` (the default) lets the runtime defer the tool; `:never` forces it to be pre-loaded. Deferring large tool sets keeps the active context smaller. + +```clojure +(def always-loaded + (copilot/define-tool "critical_action" + {:description "A tool that must always be available" + :defer :never + :parameters {:type "object" :properties {}} + :handler (fn [_ _] (copilot/result-success "done"))})) +``` + +The keyword is converted to the wire string (`:auto` -> `"auto"`, `:never` -> `"never"`); when `:defer` is omitted the field is not sent and the runtime applies its default. + **Handler return values:** | Return Type | Description | diff --git a/src/github/copilot_sdk.clj b/src/github/copilot_sdk.clj index 7615fcb..11858fb 100644 --- a/src/github/copilot_sdk.clj +++ b/src/github/copilot_sdk.clj @@ -1184,6 +1184,9 @@ - :parameters - JSON schema for parameters - :handler - Function (fn [args invocation] -> result) - :overrides-built-in-tool - When true, overrides a built-in tool of the same name + - :defer - `:auto` or `:never` (upstream PR #1632). When `:auto` the tool may be + deferred (loaded lazily via tool search); `:never` forces pre-loading. + Defaults to `:auto`. The handler receives: - args - The parsed arguments from the LLM diff --git a/src/github/copilot_sdk/client.clj b/src/github/copilot_sdk/client.clj index 801945f..d2b1fbd 100644 --- a/src/github/copilot_sdk/client.clj +++ b/src/github/copilot_sdk/client.clj @@ -1665,6 +1665,22 @@ (cond-> (dissoc lo :output-directory) dir (assoc :output-dir dir)))) +(defn- tool-def->wire + "Convert a single tool definition to its wire shape for session.create / + session.resume. Shared by both builders so the two paths cannot drift. + `:defer` (upstream PR #1632) is an idiom keyword (:auto | :never) sent as + its wire string." + [t] + (cond-> {:name (:tool-name t) + :description (:tool-description t) + :parameters (:tool-parameters t)} + (some? (:overrides-built-in-tool t)) + (assoc :overridesBuiltInTool (:overrides-built-in-tool t)) + (some? (:skip-permission? t)) + (assoc :skipPermission (:skip-permission? t)) + (some? (:defer t)) + (assoc :defer (name (:defer t))))) + (defn- config-defaults-for-mode "Mode-specific session config defaults spread UNDER the caller's config (caller's values always win). Mirrors upstream `configDefaultsForMode` @@ -1791,15 +1807,7 @@ (when-let [servers (:mcp-servers config)] (ensure-valid-mcp-servers! servers)) (let [wire-tools (when (:tools config) - (mapv (fn [t] - (cond-> {:name (:tool-name t) - :description (:tool-description t) - :parameters (:tool-parameters t)} - (some? (:overrides-built-in-tool t)) - (assoc :overridesBuiltInTool (:overrides-built-in-tool t)) - (some? (:skip-permission? t)) - (assoc :skipPermission (:skip-permission? t)))) - (:tools config))) + (mapv tool-def->wire (:tools config))) wire-sys-msg (when-let [sm (:system-message config)] (system-message->wire sm)) wire-provider (when-let [provider (:provider config)] @@ -1949,15 +1957,7 @@ (when-let [servers (:mcp-servers config)] (ensure-valid-mcp-servers! servers)) (let [wire-tools (when (:tools config) - (mapv (fn [t] - (cond-> {:name (:tool-name t) - :description (:tool-description t) - :parameters (:tool-parameters t)} - (some? (:overrides-built-in-tool t)) - (assoc :overridesBuiltInTool (:overrides-built-in-tool t)) - (some? (:skip-permission? t)) - (assoc :skipPermission (:skip-permission? t)))) - (:tools config))) + (mapv tool-def->wire (:tools config))) wire-sys-msg (when-let [sm (:system-message config)] (system-message->wire sm)) wire-provider (when-let [provider (:provider config)] diff --git a/src/github/copilot_sdk/specs.clj b/src/github/copilot_sdk/specs.clj index ce98bd2..cb5c1d9 100644 --- a/src/github/copilot_sdk/specs.clj +++ b/src/github/copilot_sdk/specs.clj @@ -235,6 +235,10 @@ (s/def ::tool-handler fn?) (s/def ::overrides-built-in-tool boolean?) (s/def ::skip-permission? boolean?) +;; Upstream PR #1632: controls whether a tool may be deferred (loaded lazily via +;; tool search) rather than always pre-loaded. The idiom uses keywords; the +;; value is sent on the wire as the corresponding string ("auto" | "never"). +(s/def ::defer #{:auto :never}) (s/def ::tool ;; Upstream PR #1308: handler is now optional. Tools without a handler are @@ -242,7 +246,7 @@ ;; and the consumer resolves them via `handle-pending-tool-call!`. (s/keys :req-un [::tool-name] :opt-un [::tool-handler ::tool-description ::tool-parameters - ::overrides-built-in-tool ::skip-permission?])) + ::overrides-built-in-tool ::skip-permission? ::defer])) (s/def ::tools (s/coll-of ::tool)) diff --git a/src/github/copilot_sdk/tools.clj b/src/github/copilot_sdk/tools.clj index 6ce9fc2..ff0d7a8 100644 --- a/src/github/copilot_sdk/tools.clj +++ b/src/github/copilot_sdk/tools.clj @@ -23,6 +23,9 @@ (or the async ``copilot/ {:tool-name name :tool-description description :tool-parameters parameters} @@ -64,7 +67,9 @@ (some? handler) (assoc :tool-handler handler) (some? overrides-built-in-tool) - (assoc :overrides-built-in-tool overrides-built-in-tool))) + (assoc :overrides-built-in-tool overrides-built-in-tool) + (some? defer) + (assoc :defer defer))) (defn define-tool-from-spec "Define a tool using a clojure.spec for parameter validation. @@ -93,7 +98,10 @@ validation occurs in the declaration-only path). - :overrides-built-in-tool - When true, overrides a built-in tool of the same name - + - :defer - `:auto` or `:never` (upstream PR #1632). When `:auto` the tool may be + deferred (loaded lazily via tool search); `:never` forces pre-loading. + Defaults to `:auto`. + Example (with handler): ```clojure (s/def ::location string?) @@ -117,7 +125,7 @@ ;; Resolve pending calls via ;; (copilot/handle-pending-tool-call! session {:request-id ... :result ...}) ```" - [name {:keys [description spec handler overrides-built-in-tool]}] + [name {:keys [description spec handler overrides-built-in-tool defer]}] ;; For now, we don't auto-convert spec to JSON schema ;; The handler should validate using the spec (cond-> {:tool-name name @@ -134,7 +142,9 @@ :error "spec validation failed"} (handler args invocation)))) (some? overrides-built-in-tool) - (assoc :overrides-built-in-tool overrides-built-in-tool))) + (assoc :overrides-built-in-tool overrides-built-in-tool) + (some? defer) + (assoc :defer defer))) (defn result-success "Create a successful tool result." diff --git a/test/github/copilot_sdk/integration_test.clj b/test/github/copilot_sdk/integration_test.clj index 50a9bc4..ace899a 100644 --- a/test/github/copilot_sdk/integration_test.clj +++ b/test/github/copilot_sdk/integration_test.clj @@ -1376,6 +1376,79 @@ (is (not (contains? wire-tool :overridesBuiltInTool)) "overridesBuiltInTool should be absent when not set")))) +(deftest test-defer-on-wire + ;; Upstream PR #1632: tool definitions accept an optional `defer` of + ;; "auto" | "never". The idiom uses keywords (:auto / :never) and the + ;; keyword is converted to its wire string via (name kw). + (testing "defer is sent on the wire on session.create (keyword -> string)" + (let [seen (atom {}) + _ (mock/set-request-hook! *mock-server* (fn [method params] + (when (#{"session.create"} method) + (swap! seen assoc method params)))) + tool (sdk/define-tool "lookup_issue" + {:description "Fetch issue details" + :defer :auto + :handler (fn [_ _] "ok")}) + _ (sdk/create-session *test-client* {:on-permission-request sdk/approve-all :tools [tool]}) + wire-tool (first (:tools (get @seen "session.create")))] + (is (some? wire-tool) "tool should be present in wire payload") + (is (= "auto" (:defer wire-tool)) + "defer :auto must be sent as the wire string \"auto\""))) + + (testing "defer :never is sent as the wire string \"never\"" + (let [seen (atom {}) + _ (mock/set-request-hook! *mock-server* (fn [method params] + (when (#{"session.create"} method) + (swap! seen assoc method params)))) + tool (sdk/define-tool "lookup_issue" + {:description "Fetch issue details" + :defer :never + :handler (fn [_ _] "ok")}) + _ (sdk/create-session *test-client* {:on-permission-request sdk/approve-all :tools [tool]}) + wire-tool (first (:tools (get @seen "session.create")))] + (is (= "never" (:defer wire-tool)) + "defer :never must be sent as the wire string \"never\""))) + + (testing "defer is absent on the wire when not set" + (let [seen (atom {}) + _ (mock/set-request-hook! *mock-server* (fn [method params] + (when (#{"session.create"} method) + (swap! seen assoc method params)))) + tool (sdk/define-tool "my_tool" {:description "A tool" :handler (fn [_ _] "ok")}) + _ (sdk/create-session *test-client* {:on-permission-request sdk/approve-all :tools [tool]}) + wire-tool (first (:tools (get @seen "session.create")))] + (is (some? wire-tool) "tool should be present in wire payload") + (is (not (contains? wire-tool :defer)) + "defer should be absent when not set"))) + + (testing "defer is sent on the wire on session.resume" + (let [seen (atom {}) + _ (mock/set-request-hook! *mock-server* (fn [method params] + (when (#{"session.resume"} method) + (swap! seen assoc method params)))) + tool (sdk/define-tool "lookup_issue" + {:description "Fetch issue details" + :defer :auto + :handler (fn [_ _] "ok")}) + _ (sdk/create-session *test-client* {:on-permission-request sdk/approve-all}) + session-id (sdk/get-last-session-id *test-client*) + _ (sdk/resume-session *test-client* session-id + {:on-permission-request sdk/approve-all :tools [tool]}) + wire-tool (first (:tools (get @seen "session.resume")))] + (is (some? wire-tool) "tool should be present in resume wire payload") + (is (= "auto" (:defer wire-tool)) + "defer must be sent on session.resume too")))) + +(deftest test-defer-spec + (testing "::tool accepts :defer :auto and :never" + (is (s/valid? ::specs/tool {:tool-name "t" :defer :auto})) + (is (s/valid? ::specs/tool {:tool-name "t" :defer :never}))) + (testing "::tool rejects an invalid or non-keyword :defer" + (is (not (s/valid? ::specs/tool {:tool-name "t" :defer "auto"})) + "wire string is not a valid idiom value") + (is (not (s/valid? ::specs/tool {:tool-name "t" :defer :bogus})) + ":bogus is not a member of #{:auto :never}"))) + ;; ----------------------------------------------------------------------------- ;; Permission Tests (upstream PR #509: deny-by-default) ;; -----------------------------------------------------------------------------