claude-agent-sdk 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +1 -1
- data/docs/rails.md +16 -0
- data/lib/claude_agent_sdk/fiber_boundary.rb +41 -7
- data/lib/claude_agent_sdk/query.rb +16 -11
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +53 -12
- data/lib/claude_agent_sdk/types.rb +19 -1
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +46 -23
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d4e4fcd2fa1c18916f41409482c8edfe3367ea5b06db9a90b80ed168f339a45e
|
|
4
|
+
data.tar.gz: 3726daa4aa431b4dba5cd181433066bc8f541efbe13984799c8642bfc671e75e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 00cb37c301d1c1eae778bd55b0d19330e21b6069a0943a6cd7a7931da4d32d8da847c8362f102d530aed8cdcf66f28eb9ca57ac248ed5ce2cbcd5609b3435ab0
|
|
7
|
+
data.tar.gz: 0bcabed11ba7e49c78ab383bf1186d39cd65873e1190f13a7cbfc102f1c2c7f0226fc8604818f3fc4cf3706760334abb5bc82a7c9ab6e6c9d1e178fd3a1c3002
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.26.0] - 2026-07-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`ClaudeAgentOptions#callback_wrapper`** (#47 phase 2): optional middleware wrapped around every user-callback dispatch (message blocks, observers, hooks, permission callbacks, SDK MCP handlers). A callable receiving a zero-arg invocation that it must call and return: `callback_wrapper: ->(inv) { Rails.application.executor.wrap { inv.call } }`. The wrapper runs on the same execution context as the callback — inside the worker thread in the default `:thread` mode (so `executor.wrap` checks ActiveRecord connections back in when the callback ends, retiring the stranded-connection workaround without adopting `:inline`), in place on the reactor fiber in `:inline` mode. Exceptions propagate through it unchanged. Also settable per SDK MCP server for direct calls (`server.callback_wrapper=`); session dispatches carry the session's wrapper via the same fiber-storage scope as `callback_scheduling`. See "Rails executor around callbacks" in docs/rails.md.
|
|
14
|
+
|
|
10
15
|
## [0.25.0] - 2026-07-31
|
|
11
16
|
|
|
12
17
|
### Added
|
data/README.md
CHANGED
|
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
|
|
|
68
68
|
gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
|
|
69
69
|
|
|
70
70
|
# Or use a stable version from RubyGems
|
|
71
|
-
gem 'claude-agent-sdk', '~> 0.
|
|
71
|
+
gem 'claude-agent-sdk', '~> 0.26.0'
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
|
data/docs/rails.md
CHANGED
|
@@ -21,6 +21,22 @@ end
|
|
|
21
21
|
|
|
22
22
|
The trade-off: because callbacks run on a plain thread rather than inside an `Async::Task`, fiber-specific primitives aren't available to them — `Async::Task.current` will raise "No async task available". If a callback wants cooperative concurrency it should open its own `Async { }` block. In practice, callbacks typically do some Ruby work, call external services, and return — so this rarely matters. If you wrap your own call site in an outer `Async { }` block, the scheduler is visible to your code again; you've opted in, and whatever fiber-safety rules your app uses apply there.
|
|
23
23
|
|
|
24
|
+
### Rails executor around callbacks: `callback_wrapper`
|
|
25
|
+
|
|
26
|
+
One consequence of the thread hop: an ActiveRecord connection implicitly checked out inside a callback belongs to that throwaway thread and stays stranded until the pool reaper reclaims it. Rails' own answer to "code running on a thread Rails didn't create" is the executor — and `callback_wrapper` lets you install it around every user-callback dispatch:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
ClaudeAgentSDK.configure do |config|
|
|
30
|
+
config.default_options = {
|
|
31
|
+
callback_wrapper: ->(invocation) { Rails.application.executor.wrap { invocation.call } }
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The wrapper is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: `executor.wrap` runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers like `executor.wrap` are safe, including around a `break` from a message block. Beyond the executor, this is a generic hook for APM span propagation, `CurrentAttributes`/logging context, etc.
|
|
37
|
+
|
|
38
|
+
When do you want this vs `callback_scheduling: :inline`? `callback_wrapper` + default `:thread` mode is the right choice for ordinary threaded hosts (Puma, threaded Sidekiq/solid_queue): it fixes connection hygiene without any fiber-isolation precondition. `:inline` is only for hosts that are fiber-isolated end to end (solid_queue fiber workers with `isolation_level = :fiber`); there the wrapper still applies — it simply runs in place on the reactor fiber.
|
|
39
|
+
|
|
24
40
|
## Fiber workers (solid_queue) and `callback_scheduling: :inline`
|
|
25
41
|
|
|
26
42
|
[solid_queue 728](https://github.com/rails/solid_queue/pull/728) added a fiber-based worker mode: workers configured with `fibers: N` run claimed jobs as fibers on one async reactor thread — built for exactly the long-lived, I/O-bound "LLM streaming" jobs this SDK produces. It requires the app to be fiber-isolated end to end:
|
|
@@ -97,12 +97,16 @@ module ClaudeAgentSDK
|
|
|
97
97
|
# fall back to the server's own default. Benign race on close vs. a
|
|
98
98
|
# concurrent reader: either value is a defensible mode for a call that
|
|
99
99
|
# straddles the dispatch boundary.
|
|
100
|
+
# Also carries the session's callback wrapper — the two travel (and are
|
|
101
|
+
# invalidated) together, so a descendant that outlives the dispatch can
|
|
102
|
+
# neither run with the session's mode nor with its wrapper.
|
|
100
103
|
# @api private
|
|
101
104
|
class SchedulingScope
|
|
102
|
-
attr_reader :mode
|
|
105
|
+
attr_reader :mode, :wrapper
|
|
103
106
|
|
|
104
|
-
def initialize(mode)
|
|
107
|
+
def initialize(mode, wrapper = nil)
|
|
105
108
|
@mode = mode
|
|
109
|
+
@wrapper = wrapper
|
|
106
110
|
@active = true
|
|
107
111
|
end
|
|
108
112
|
|
|
@@ -137,10 +141,31 @@ module ClaudeAgentSDK
|
|
|
137
141
|
# With `scheduling: :inline` (and no timeout) the block runs in place on
|
|
138
142
|
# the current fiber, scheduler or not. The caller opts in via
|
|
139
143
|
# `ClaudeAgentOptions#callback_scheduling`.
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
#
|
|
145
|
+
# With +wrapper+ (a callable, from `ClaudeAgentOptions#callback_wrapper`)
|
|
146
|
+
# the executed body becomes `wrapper.call(block)` — composed BEFORE the
|
|
147
|
+
# thread hop, so the wrapper runs on the same execution context as the
|
|
148
|
+
# callback: on the worker thread in :thread mode (the whole point —
|
|
149
|
+
# `Rails.application.executor.wrap` must run on the thread that touches
|
|
150
|
+
# ActiveRecord), in place on the reactor fiber in :inline mode. The
|
|
151
|
+
# wrapper must call its argument and return its value; exceptions from
|
|
152
|
+
# the callback propagate through it unchanged, and exceptions raised by
|
|
153
|
+
# the wrapper itself are treated exactly like callback exceptions. The
|
|
154
|
+
# wrapper must not swallow exceptions and must return
|
|
155
|
+
# the invocation's value — a user's `break` in a message block reaches
|
|
156
|
+
# the wrapper as a clean return (invoke_iteration translates it BEFORE
|
|
157
|
+
# the wrapper sees it), never as an exception a rescue/report wrapper
|
|
158
|
+
# could falsely flag. In inline/no-scheduler mode `break` unwinds natively through the
|
|
159
|
+
# wrapper's stack, so ensure-based wrappers (executor.wrap) are safe.
|
|
160
|
+
#
|
|
161
|
+
# The timeout path deliberately ignores the wrapper: it belongs to the
|
|
162
|
+
# store-adapter carve-out (TranscriptMirrorBatcher, SessionResume),
|
|
163
|
+
# which never carries user callbacks.
|
|
164
|
+
def invoke(timeout: nil, scheduling: :thread, wrapper: nil, &block)
|
|
165
|
+
body = wrapper && timeout.nil? ? -> { wrapper.call(block) } : block
|
|
166
|
+
return body.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler)
|
|
142
167
|
|
|
143
|
-
thread = Thread.new(&
|
|
168
|
+
thread = Thread.new(&body)
|
|
144
169
|
thread.report_on_exception = false
|
|
145
170
|
return thread.value if timeout.nil?
|
|
146
171
|
raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout)
|
|
@@ -155,8 +180,15 @@ module ClaudeAgentSDK
|
|
|
155
180
|
# Returns Break when the user broke, nil when the block completed.
|
|
156
181
|
# Without a scheduler (or with `scheduling: :inline`) the block runs in
|
|
157
182
|
# place and `break` unwinds natively, never reaching the translation.
|
|
158
|
-
|
|
159
|
-
|
|
183
|
+
# The LocalJumpError -> Break translation lives INSIDE the invocation
|
|
184
|
+
# handed to the +wrapper+: a user's `break` is normal loop control, not
|
|
185
|
+
# an error, so a conforming rescue/report/re-raise wrapper must observe
|
|
186
|
+
# a clean return (the Break sentinel as the invocation's value), never
|
|
187
|
+
# a LocalJumpError it could falsely report or swallow. In inline /
|
|
188
|
+
# no-scheduler mode `break` unwinds natively through the wrapper's
|
|
189
|
+
# stack instead (ensure-based wrappers still run their cleanup).
|
|
190
|
+
def invoke_iteration(block, *args, scheduling: :thread, wrapper: nil)
|
|
191
|
+
invocation = lambda do
|
|
160
192
|
block.call(*args)
|
|
161
193
|
nil
|
|
162
194
|
rescue LocalJumpError => e
|
|
@@ -164,6 +196,8 @@ module ClaudeAgentSDK
|
|
|
164
196
|
|
|
165
197
|
Break.new(e.exit_value)
|
|
166
198
|
end
|
|
199
|
+
body = wrapper ? -> { wrapper.call(invocation) } : invocation
|
|
200
|
+
invoke(scheduling: scheduling) { body.call }
|
|
167
201
|
end
|
|
168
202
|
end
|
|
169
203
|
end
|
|
@@ -63,13 +63,14 @@ module ClaudeAgentSDK
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
|
|
66
|
-
exclude_dynamic_sections: nil, skills: nil, callback_scheduling: :thread)
|
|
66
|
+
exclude_dynamic_sections: nil, skills: nil, callback_scheduling: :thread, callback_wrapper: nil)
|
|
67
67
|
@transport = transport
|
|
68
68
|
@is_streaming_mode = is_streaming_mode
|
|
69
69
|
@can_use_tool = can_use_tool
|
|
70
70
|
@hooks = hooks || {}
|
|
71
71
|
@sdk_mcp_servers = sdk_mcp_servers || {}
|
|
72
72
|
@callback_scheduling = callback_scheduling || :thread
|
|
73
|
+
@callback_wrapper = callback_wrapper
|
|
73
74
|
@agents = agents
|
|
74
75
|
@exclude_dynamic_sections = exclude_dynamic_sections
|
|
75
76
|
@skills = skills
|
|
@@ -570,7 +571,7 @@ module ClaudeAgentSDK
|
|
|
570
571
|
# with callback_scheduling: :inline it runs in place on this control-
|
|
571
572
|
# request task, where control_cancel_request (task.stop) can actually
|
|
572
573
|
# cancel it at suspension points.
|
|
573
|
-
response = FiberBoundary.invoke(scheduling: @callback_scheduling) do
|
|
574
|
+
response = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
|
|
574
575
|
@can_use_tool.call(request_data[:tool_name], request_data[:input], context)
|
|
575
576
|
end
|
|
576
577
|
|
|
@@ -615,7 +616,7 @@ module ClaudeAgentSDK
|
|
|
615
616
|
# suspension point and its ensure blocks run (Python parity — anyio
|
|
616
617
|
# cancels the coroutine). A CPU-stuck inline hook cannot be timed out.
|
|
617
618
|
unless @hook_callback_timeouts[callback_id]
|
|
618
|
-
hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling) do
|
|
619
|
+
hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
|
|
619
620
|
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
620
621
|
end
|
|
621
622
|
end
|
|
@@ -629,10 +630,13 @@ module ClaudeAgentSDK
|
|
|
629
630
|
# convert the expired hook into a success (or keep running past
|
|
630
631
|
# the deadline). Inject a non-StandardError cancellation
|
|
631
632
|
# instead, translated back once control leaves user code so the
|
|
632
|
-
# outward contract (Async::TimeoutError) is unchanged.
|
|
633
|
+
# outward contract (Async::TimeoutError) is unchanged. The
|
|
634
|
+
# wrapper composes INSIDE with_timeout, and the cancellation
|
|
635
|
+
# passes through it un-swallowed (InlineCancellation is not a
|
|
636
|
+
# StandardError, so a wrapper's ordinary rescue can't eat it).
|
|
633
637
|
begin
|
|
634
638
|
Async::Task.current.with_timeout(timeout, FiberBoundary::InlineCancellation) do
|
|
635
|
-
FiberBoundary.invoke(scheduling: :inline) do
|
|
639
|
+
FiberBoundary.invoke(scheduling: :inline, wrapper: @callback_wrapper) do
|
|
636
640
|
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
637
641
|
end
|
|
638
642
|
end
|
|
@@ -641,7 +645,7 @@ module ClaudeAgentSDK
|
|
|
641
645
|
end
|
|
642
646
|
else
|
|
643
647
|
Async::Task.current.with_timeout(timeout) do
|
|
644
|
-
FiberBoundary.invoke do
|
|
648
|
+
FiberBoundary.invoke(wrapper: @callback_wrapper) do
|
|
645
649
|
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
646
650
|
end
|
|
647
651
|
end
|
|
@@ -994,10 +998,11 @@ module ClaudeAgentSDK
|
|
|
994
998
|
end
|
|
995
999
|
|
|
996
1000
|
def handle_sdk_mcp_request(server_name, message)
|
|
997
|
-
# Carry this session's scheduling mode
|
|
998
|
-
# (possibly session-shared) SdkMcpServer via fiber
|
|
999
|
-
# the dispatching fiber, read back by the server's
|
|
1000
|
-
# time (see
|
|
1001
|
+
# Carry this session's scheduling mode and callback wrapper across the
|
|
1002
|
+
# dispatch into the (possibly session-shared) SdkMcpServer via fiber
|
|
1003
|
+
# storage — set on the dispatching fiber, read back by the server's
|
|
1004
|
+
# handlers at invoke time (see
|
|
1005
|
+
# SdkMcpServer#effective_callback_scheduling / _wrapper). Fiber
|
|
1001
1006
|
# storage is per-fiber, so concurrent sessions cannot see each
|
|
1002
1007
|
# other's value even across suspension points. The value is a
|
|
1003
1008
|
# closable SchedulingScope, closed + restored in the ensure below:
|
|
@@ -1008,7 +1013,7 @@ module ClaudeAgentSDK
|
|
|
1008
1013
|
# into later direct server calls, and nothing stays stamped on
|
|
1009
1014
|
# long-lived fibers.
|
|
1010
1015
|
previous_scheduling = Fiber[FiberBoundary::SCHEDULING_KEY]
|
|
1011
|
-
dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling)
|
|
1016
|
+
dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling, @callback_wrapper)
|
|
1012
1017
|
Fiber[FiberBoundary::SCHEDULING_KEY] = dispatch_scope
|
|
1013
1018
|
|
|
1014
1019
|
# Convert server_name to symbol if needed for hash lookup
|
|
@@ -90,17 +90,47 @@ module ClaudeAgentSDK
|
|
|
90
90
|
# so modes cannot cross-contaminate or persist past a session.
|
|
91
91
|
attr_accessor :callback_scheduling
|
|
92
92
|
|
|
93
|
+
# Default callback wrapper for DIRECT invocations of this server
|
|
94
|
+
# (call_tool / read_resource / get_prompt outside a session). When a
|
|
95
|
+
# session dispatches to this server, the session's own wrapper arrives
|
|
96
|
+
# via fiber storage instead (see #effective_callback_wrapper) — same
|
|
97
|
+
# never-mutate-the-shared-server rule as callback_scheduling.
|
|
98
|
+
attr_accessor :callback_wrapper
|
|
99
|
+
|
|
93
100
|
# Internal — public only so the dynamic tool classes can reach it. The
|
|
94
|
-
# scheduling mode for the current invocation
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
101
|
+
# (scheduling mode, wrapper) pair for the current invocation, resolved
|
|
102
|
+
# from ONE liveness decision: the dispatching session's pair when a
|
|
103
|
+
# live SchedulingScope is in fiber storage (set by Query around the
|
|
104
|
+
# dispatch), else this server's own defaults. The pair MUST be resolved
|
|
105
|
+
# together — deciding `active?` once per accessor lets the scope close
|
|
106
|
+
# between the two reads (the dispatch's ensure runs concurrently with a
|
|
107
|
+
# descendant reader) and yields a torn mix of session mode with server
|
|
108
|
+
# wrapper. A scope inherited from an already-finished dispatch is
|
|
109
|
+
# closed and deliberately ignored — a child task spawned inside a
|
|
110
|
+
# handler must not carry the session's pair into later direct calls.
|
|
100
111
|
# @api private
|
|
101
|
-
|
|
112
|
+
# @return [Array(Symbol, #call)] `[scheduling, wrapper]`
|
|
113
|
+
def effective_callback_dispatch
|
|
102
114
|
scope = Fiber[FiberBoundary::SCHEDULING_KEY]
|
|
103
|
-
scope&.active?
|
|
115
|
+
if scope&.active?
|
|
116
|
+
[scope.mode, scope.wrapper]
|
|
117
|
+
else
|
|
118
|
+
[@callback_scheduling, @callback_wrapper]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Internal. Prefer #effective_callback_dispatch when both values are
|
|
123
|
+
# needed — separate calls re-decide scope liveness and can tear.
|
|
124
|
+
# @api private
|
|
125
|
+
def effective_callback_scheduling
|
|
126
|
+
effective_callback_dispatch[0]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Internal. Prefer #effective_callback_dispatch when both values are
|
|
130
|
+
# needed — separate calls re-decide scope liveness and can tear.
|
|
131
|
+
# @api private
|
|
132
|
+
def effective_callback_wrapper
|
|
133
|
+
effective_callback_dispatch[1]
|
|
104
134
|
end
|
|
105
135
|
|
|
106
136
|
def initialize(name:, version: '1.0.0', tools: [], resources: [], prompts: [])
|
|
@@ -110,6 +140,7 @@ module ClaudeAgentSDK
|
|
|
110
140
|
@resources = resources
|
|
111
141
|
@prompts = prompts
|
|
112
142
|
@callback_scheduling = :thread
|
|
143
|
+
@callback_wrapper = nil
|
|
113
144
|
|
|
114
145
|
# Create dynamic Tool classes from tool definitions
|
|
115
146
|
tool_classes = create_tool_classes(tools)
|
|
@@ -197,7 +228,10 @@ module ClaudeAgentSDK
|
|
|
197
228
|
# Call the tool's handler on a plain thread (default) so the async
|
|
198
229
|
# gem's Fiber scheduler is not visible to user code (which may hit
|
|
199
230
|
# AR/PG); in :inline mode it runs in place on the reactor fiber.
|
|
200
|
-
|
|
231
|
+
scheduling, wrapper = effective_callback_dispatch
|
|
232
|
+
result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
233
|
+
tool.handler.call(arguments)
|
|
234
|
+
end
|
|
201
235
|
|
|
202
236
|
# Guard before flexible_fetch: it raises on non-Hash inputs.
|
|
203
237
|
content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "content", "content") : nil
|
|
@@ -232,7 +266,10 @@ module ClaudeAgentSDK
|
|
|
232
266
|
# Hop off the Fiber scheduler before invoking user code — same reason
|
|
233
267
|
# as `call_tool` above: reader blocks may touch Thread.current-keyed
|
|
234
268
|
# libraries (ActiveRecord, pg, ...) and must run on a plain thread.
|
|
235
|
-
|
|
269
|
+
scheduling, wrapper = effective_callback_dispatch
|
|
270
|
+
content = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
271
|
+
resource.reader.call
|
|
272
|
+
end
|
|
236
273
|
|
|
237
274
|
# Ensure content has the expected format (symbol or string keys; guard
|
|
238
275
|
# before flexible_fetch — it raises on non-Hash inputs)
|
|
@@ -264,7 +301,10 @@ module ClaudeAgentSDK
|
|
|
264
301
|
|
|
265
302
|
# Hop off the Fiber scheduler before invoking user code — same reason
|
|
266
303
|
# as `call_tool` above.
|
|
267
|
-
|
|
304
|
+
scheduling, wrapper = effective_callback_dispatch
|
|
305
|
+
result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
306
|
+
prompt.generator.call(arguments)
|
|
307
|
+
end
|
|
268
308
|
|
|
269
309
|
# Ensure result has the expected format (symbol or string keys)
|
|
270
310
|
messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "messages", "messages") : nil
|
|
@@ -365,7 +405,8 @@ module ClaudeAgentSDK
|
|
|
365
405
|
# Filter out server_context and pass remaining args to handler.
|
|
366
406
|
# Hop to a plain thread (default) so user handlers don't see
|
|
367
407
|
# the Fiber scheduler; :inline runs in place on the reactor.
|
|
368
|
-
|
|
408
|
+
scheduling, wrapper = @sdk_server.effective_callback_dispatch
|
|
409
|
+
result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
369
410
|
@tool_def.handler.call(args)
|
|
370
411
|
end
|
|
371
412
|
|
|
@@ -1576,7 +1576,7 @@ module ClaudeAgentSDK
|
|
|
1576
1576
|
attr_reader :bare, :fork_session, :enable_file_checkpointing,
|
|
1577
1577
|
:include_partial_messages, :continue_conversation,
|
|
1578
1578
|
:include_hook_events, :strict_mcp_config,
|
|
1579
|
-
:callback_scheduling
|
|
1579
|
+
:callback_scheduling, :callback_wrapper
|
|
1580
1580
|
|
|
1581
1581
|
def initialize(attributes = {})
|
|
1582
1582
|
self.fork_session = false
|
|
@@ -1704,6 +1704,24 @@ module ClaudeAgentSDK
|
|
|
1704
1704
|
@callback_scheduling = mode
|
|
1705
1705
|
end
|
|
1706
1706
|
|
|
1707
|
+
# Middleware wrapped around EVERY user-callback dispatch (message
|
|
1708
|
+
# blocks, observers, hooks, permission callbacks, SDK MCP handlers).
|
|
1709
|
+
# A callable receiving a zero-arg invocation; it MUST call it and
|
|
1710
|
+
# return its value:
|
|
1711
|
+
#
|
|
1712
|
+
# callback_wrapper: ->(invocation) { Rails.application.executor.wrap { invocation.call } }
|
|
1713
|
+
#
|
|
1714
|
+
# The wrapper runs on the same execution context as the callback —
|
|
1715
|
+
# inside the worker thread in :thread mode (so executor.wrap checks AR
|
|
1716
|
+
# connections back in when the callback ends), in place on the reactor
|
|
1717
|
+
# fiber in :inline mode. Exceptions propagate through it unchanged; it
|
|
1718
|
+
# must not swallow them. Default nil (no wrapping).
|
|
1719
|
+
def callback_wrapper=(value)
|
|
1720
|
+
raise ArgumentError, "callback_wrapper must be a callable or nil (got #{value.inspect})" unless value.nil? || value.respond_to?(:call)
|
|
1721
|
+
|
|
1722
|
+
@callback_wrapper = value
|
|
1723
|
+
end
|
|
1724
|
+
|
|
1707
1725
|
private
|
|
1708
1726
|
|
|
1709
1727
|
# Strict key validation: unlike other Type subclasses (which silently drop
|
data/lib/claude_agent_sdk.rb
CHANGED
|
@@ -84,9 +84,9 @@ module ClaudeAgentSDK
|
|
|
84
84
|
# Each observer is invoked through FiberBoundary so that user code runs
|
|
85
85
|
# on a plain thread (no Fiber scheduler) even when called from inside
|
|
86
86
|
# the SDK's Async reactor — or in place when scheduling is :inline.
|
|
87
|
-
def self.notify_observers(observers, method, *args, scheduling: :thread)
|
|
87
|
+
def self.notify_observers(observers, method, *args, scheduling: :thread, wrapper: nil)
|
|
88
88
|
observers.each do |obs|
|
|
89
|
-
FiberBoundary.invoke(scheduling: scheduling) { obs.send(method, *args) }
|
|
89
|
+
FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) { obs.send(method, *args) }
|
|
90
90
|
rescue StandardError, ScriptError
|
|
91
91
|
# ScriptError too: NotImplementedError < ScriptError (not
|
|
92
92
|
# StandardError), and a stubbed observer must never mask the original
|
|
@@ -187,13 +187,13 @@ module ClaudeAgentSDK
|
|
|
187
187
|
# Wrap a streaming-input enumerable so observers get on_user_prompt for
|
|
188
188
|
# each user message before it is written to stdin. Identity when no
|
|
189
189
|
# observers are configured.
|
|
190
|
-
def self.observing_prompt_stream(prompt, observers, scheduling: :thread)
|
|
190
|
+
def self.observing_prompt_stream(prompt, observers, scheduling: :thread, wrapper: nil)
|
|
191
191
|
return prompt if observers.empty?
|
|
192
192
|
|
|
193
193
|
Enumerator.new do |yielder|
|
|
194
194
|
prompt.each do |message|
|
|
195
195
|
text = extract_user_prompt_text(message)
|
|
196
|
-
notify_observers(observers, :on_user_prompt, text, scheduling: scheduling) if text
|
|
196
|
+
notify_observers(observers, :on_user_prompt, text, scheduling: scheduling, wrapper: wrapper) if text
|
|
197
197
|
yielder << message
|
|
198
198
|
end
|
|
199
199
|
end
|
|
@@ -461,8 +461,10 @@ module ClaudeAgentSDK
|
|
|
461
461
|
# Resolve callable observers into fresh instances (thread-safe for global defaults)
|
|
462
462
|
resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers)
|
|
463
463
|
|
|
464
|
-
# Where user callbacks run (see ClaudeAgentOptions#callback_scheduling)
|
|
464
|
+
# Where user callbacks run (see ClaudeAgentOptions#callback_scheduling)
|
|
465
|
+
# and the middleware wrapped around them (#callback_wrapper).
|
|
465
466
|
callback_scheduling = configured_options.callback_scheduling || :thread
|
|
467
|
+
callback_wrapper = configured_options.callback_wrapper
|
|
466
468
|
ClaudeAgentSDK.check_inline_isolation(callback_scheduling)
|
|
467
469
|
|
|
468
470
|
raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' if transport && !transport.respond_to?(:connect)
|
|
@@ -525,7 +527,8 @@ module ClaudeAgentSDK
|
|
|
525
527
|
sdk_mcp_servers: sdk_mcp_servers,
|
|
526
528
|
exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
|
|
527
529
|
skills: configured_options.skills,
|
|
528
|
-
callback_scheduling: callback_scheduling
|
|
530
|
+
callback_scheduling: callback_scheduling,
|
|
531
|
+
callback_wrapper: callback_wrapper
|
|
529
532
|
)
|
|
530
533
|
|
|
531
534
|
# Mirror transcripts to the session_store, if configured. Installed
|
|
@@ -549,7 +552,8 @@ module ClaudeAgentSDK
|
|
|
549
552
|
|
|
550
553
|
# Send prompt(s) as user messages, then close stdin
|
|
551
554
|
if prompt.is_a?(String)
|
|
552
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt,
|
|
555
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt,
|
|
556
|
+
scheduling: callback_scheduling, wrapper: callback_wrapper)
|
|
553
557
|
message = {
|
|
554
558
|
type: 'user',
|
|
555
559
|
message: { role: 'user', content: prompt },
|
|
@@ -567,7 +571,8 @@ module ClaudeAgentSDK
|
|
|
567
571
|
# here kept the root reactor alive forever when the read loop died
|
|
568
572
|
# while the user enumerator was still blocked (matches Python's
|
|
569
573
|
# query.spawn_task(query.stream_input(prompt))).
|
|
570
|
-
observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers,
|
|
574
|
+
observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers,
|
|
575
|
+
scheduling: callback_scheduling, wrapper: callback_wrapper)
|
|
571
576
|
query_handler.spawn_task { query_handler.stream_input(observed_prompt) }
|
|
572
577
|
end
|
|
573
578
|
|
|
@@ -579,8 +584,10 @@ module ClaudeAgentSDK
|
|
|
579
584
|
message = MessageParser.parse(data)
|
|
580
585
|
next unless message
|
|
581
586
|
|
|
582
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message,
|
|
583
|
-
|
|
587
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message,
|
|
588
|
+
scheduling: callback_scheduling, wrapper: callback_wrapper)
|
|
589
|
+
signal = FiberBoundary.invoke_iteration(block, message, scheduling: callback_scheduling,
|
|
590
|
+
wrapper: callback_wrapper)
|
|
584
591
|
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
585
592
|
end
|
|
586
593
|
rescue StandardError => e
|
|
@@ -589,10 +596,12 @@ module ClaudeAgentSDK
|
|
|
589
596
|
# parse errors, and user-block errors. StandardError only: Async::Stop
|
|
590
597
|
# is cancellation, not an error. Bare raise preserves the backtrace;
|
|
591
598
|
# the ensure below still fires on_close after on_error.
|
|
592
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e,
|
|
599
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e,
|
|
600
|
+
scheduling: callback_scheduling, wrapper: callback_wrapper)
|
|
593
601
|
raise
|
|
594
602
|
ensure
|
|
595
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_close,
|
|
603
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_close,
|
|
604
|
+
scheduling: callback_scheduling, wrapper: callback_wrapper)
|
|
596
605
|
# query_handler.close stops the background read task and closes the
|
|
597
606
|
# transport (flushing the mirror batcher first). Fall back to a bare
|
|
598
607
|
# transport close when the handler was never built.
|
|
@@ -663,6 +672,7 @@ module ClaudeAgentSDK
|
|
|
663
672
|
def initialize(options: nil, transport_class: SubprocessCLITransport, transport_args: {})
|
|
664
673
|
@options = options || ClaudeAgentOptions.new
|
|
665
674
|
@callback_scheduling = @options.callback_scheduling || :thread
|
|
675
|
+
@callback_wrapper = @options.callback_wrapper
|
|
666
676
|
@transport_class = transport_class
|
|
667
677
|
@transport_args = transport_args
|
|
668
678
|
@transport = nil
|
|
@@ -799,7 +809,8 @@ module ClaudeAgentSDK
|
|
|
799
809
|
|
|
800
810
|
begin
|
|
801
811
|
if prompt.is_a?(String)
|
|
802
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt,
|
|
812
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt,
|
|
813
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
803
814
|
message = {
|
|
804
815
|
type: 'user',
|
|
805
816
|
message: { role: 'user', content: prompt },
|
|
@@ -840,8 +851,10 @@ module ClaudeAgentSDK
|
|
|
840
851
|
message = MessageParser.parse(data)
|
|
841
852
|
next unless message
|
|
842
853
|
|
|
843
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
|
|
844
|
-
|
|
854
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
|
|
855
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
856
|
+
signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling,
|
|
857
|
+
wrapper: @callback_wrapper)
|
|
845
858
|
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
846
859
|
end
|
|
847
860
|
rescue StandardError => e
|
|
@@ -866,8 +879,10 @@ module ClaudeAgentSDK
|
|
|
866
879
|
message = MessageParser.parse(data)
|
|
867
880
|
next unless message
|
|
868
881
|
|
|
869
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
|
|
870
|
-
|
|
882
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
|
|
883
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
884
|
+
signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling,
|
|
885
|
+
wrapper: @callback_wrapper)
|
|
871
886
|
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
872
887
|
break if message.is_a?(ResultMessage)
|
|
873
888
|
end
|
|
@@ -959,7 +974,10 @@ module ClaudeAgentSDK
|
|
|
959
974
|
|
|
960
975
|
# Disconnect from Claude
|
|
961
976
|
def disconnect
|
|
962
|
-
|
|
977
|
+
if @connected
|
|
978
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close,
|
|
979
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
980
|
+
end
|
|
963
981
|
# Tear down whatever exists — robust to a partial/failed connect, where
|
|
964
982
|
# @connected is still false but a transport and/or materialized temp dir
|
|
965
983
|
# were already created. #close on the query handler also closes the
|
|
@@ -1046,7 +1064,8 @@ module ClaudeAgentSDK
|
|
|
1046
1064
|
agents: configured_options.agents,
|
|
1047
1065
|
exclude_dynamic_sections: exclude_dynamic_sections,
|
|
1048
1066
|
skills: configured_options.skills,
|
|
1049
|
-
callback_scheduling: @callback_scheduling
|
|
1067
|
+
callback_scheduling: @callback_scheduling,
|
|
1068
|
+
callback_wrapper: @callback_wrapper
|
|
1050
1069
|
)
|
|
1051
1070
|
|
|
1052
1071
|
# Mirror transcripts to the session_store, if configured.
|
|
@@ -1077,7 +1096,8 @@ module ClaudeAgentSDK
|
|
|
1077
1096
|
# Observer#on_error contract; notifying a swallowed error would mark
|
|
1078
1097
|
# a still-live OTel trace as failed). Same behavior as query()'s
|
|
1079
1098
|
# streaming path.
|
|
1080
|
-
observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers,
|
|
1099
|
+
observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers,
|
|
1100
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
1081
1101
|
@query_handler.spawn_task { @query_handler.stream_input(observed) }
|
|
1082
1102
|
end
|
|
1083
1103
|
end
|
|
@@ -1094,12 +1114,14 @@ module ClaudeAgentSDK
|
|
|
1094
1114
|
when Hash
|
|
1095
1115
|
msg = msg.merge(session_id: session_id) unless msg.key?(:session_id) || msg.key?('session_id')
|
|
1096
1116
|
if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
|
|
1097
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
|
|
1117
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
|
|
1118
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
1098
1119
|
end
|
|
1099
1120
|
writeln(JSON.generate(msg))
|
|
1100
1121
|
when String
|
|
1101
1122
|
if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
|
|
1102
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
|
|
1123
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
|
|
1124
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
1103
1125
|
end
|
|
1104
1126
|
writeln(msg)
|
|
1105
1127
|
else
|
|
@@ -1113,7 +1135,8 @@ module ClaudeAgentSDK
|
|
|
1113
1135
|
# Notify observers of an error surfacing to the consumer. `|| []` keeps a
|
|
1114
1136
|
# mis-scoped call before connect harmless instead of NoMethodError on nil.
|
|
1115
1137
|
def notify_error(error)
|
|
1116
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error,
|
|
1138
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error,
|
|
1139
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
1117
1140
|
end
|
|
1118
1141
|
|
|
1119
1142
|
# Build and install the transcript-mirror batcher on the query handler when
|