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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
15 changes: 15 additions & 0 deletions doc/reference/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions src/github/copilot_sdk.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 18 additions & 18 deletions src/github/copilot_sdk/client.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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)]
Expand Down
6 changes: 5 additions & 1 deletion src/github/copilot_sdk/specs.clj
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,18 @@
(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
;; declaration-only — they're surfaced as `external_tool.requested` events
;; 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))

Expand Down
20 changes: 15 additions & 5 deletions src/github/copilot_sdk/tools.clj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
(or the async ``copilot/<handle-pending-tool-call!``).
- :overrides-built-in-tool - When true, explicitly overrides a built-in tool of the same name.
Without this flag, name clashes with built-in tools cause an error.
- :defer - `:auto` or `:never` (upstream PR #1632). Controls whether the tool may
be deferred (loaded lazily via tool search) rather than always pre-loaded.
`:auto` allows deferral; `:never` forces pre-loading. Defaults to `:auto`.

The handler (when provided) receives:
- args - The parsed arguments from the LLM (no key conversion)
Expand Down Expand Up @@ -54,7 +57,7 @@
;; Listen for :copilot/external_tool.requested events and resolve via
;; (copilot/handle-pending-tool-call! session {:request-id ... :result ...})
```"
[name {:keys [description parameters handler overrides-built-in-tool]}]
[name {:keys [description parameters handler overrides-built-in-tool defer]}]
(cond-> {:tool-name name
:tool-description description
:tool-parameters parameters}
Expand All @@ -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.
Expand Down Expand Up @@ -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?)
Expand All @@ -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
Expand All @@ -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."
Expand Down
73 changes: 73 additions & 0 deletions test/github/copilot_sdk/integration_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
;; -----------------------------------------------------------------------------
Expand Down
Loading