claude-agent-sdk 0.24.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 +11 -0
- data/README.md +2 -2
- data/docs/rails.md +62 -1
- data/lib/claude_agent_sdk/fiber_boundary.rb +119 -9
- data/lib/claude_agent_sdk/query.rb +66 -13
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +79 -7
- data/lib/claude_agent_sdk/types.rb +52 -1
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +96 -24
- metadata +2 -2
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,17 @@ 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
|
+
|
|
15
|
+
## [0.25.0] - 2026-07-31
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **Opt-in fiber-native callback execution: `callback_scheduling: :inline`** (#47). By default the SDK hops every user callback (message blocks, hooks, permission callbacks, SDK MCP handlers, observers) to a plain thread so thread-keyed libraries (ActiveRecord, pg, …) never see the async gem's Fiber scheduler. Hosts that are fiber-isolated end to end — e.g. solid_queue fiber workers (`fibers: N`) with `ActiveSupport::IsolatedExecutionState.isolation_level = :fiber` — can now pass `ClaudeAgentOptions.new(callback_scheduling: :inline)` (or set it globally via `ClaudeAgentSDK.configure`) to run callbacks in place on the reactor fiber: `Fiber.scheduler` is live inside callbacks, no per-call threads exist to strand AR connections, and the session can live directly on the job fiber. This matches the Python SDK's execution model (async callbacks run natively on the event loop). Default behavior is unchanged. In `:inline` mode hook timeouts become cooperative cancellations (the hook is interrupted at its next suspension point and its `ensure` blocks run) instead of hard thread abandonment; `SessionStore` adapter calls intentionally stay on threads so a wedged adapter can never stall the reactor. The SDK warns once when `:inline` is enabled under `isolation_level == :thread`. See "Fiber workers (solid_queue)" in docs/rails.md.
|
|
19
|
+
- `ClaudeAgentSDK.offload { }` — public escape hatch for `:inline` hosts: runs a heavy piece of a callback on a plain thread instead of the reactor fiber. Shields the reactor from scheduler-opaque blocking that releases the GVL (native DB drivers, file/socket I/O); turns pure-Ruby CPU work into GVL time-slicing instead of a hard stall. A C extension that holds the GVL for the whole computation still freezes the process — move such work to a subprocess. No-op outside a reactor.
|
|
20
|
+
|
|
10
21
|
## [0.24.0] - 2026-07-27
|
|
11
22
|
|
|
12
23
|
Parity batch with the Python SDK v0.2.111–v0.2.128 (everything substantive in that span; the rest is bundled-CLI version bumps, which don't apply to this gem).
|
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`.
|
|
@@ -210,7 +210,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
|
210
210
|
| Structured output, thinking config, budget, fallback model, advisor model, beta features, sandbox, bare mode, file checkpointing | [docs/configuration.md](docs/configuration.md) |
|
|
211
211
|
| Session listing, reading, renaming, tagging, deleting, forking, resume-at-message | [docs/sessions.md](docs/sessions.md) |
|
|
212
212
|
| OpenTelemetry tracing, Langfuse setup, custom observers | [docs/observability.md](docs/observability.md) |
|
|
213
|
-
| Rails integration (fiber safety, ActionCable, sessions, jobs, HTTP MCP, observability initializer) | [docs/rails.md](docs/rails.md) |
|
|
213
|
+
| Rails integration (fiber safety, solid_queue fiber workers / `callback_scheduling: :inline`, ActionCable, sessions, jobs, HTTP MCP, observability initializer) | [docs/rails.md](docs/rails.md) |
|
|
214
214
|
| Message, content block, and configuration type reference | [docs/types.md](docs/types.md) |
|
|
215
215
|
| Error handling, exception hierarchy, timeout configuration | [docs/errors.md](docs/errors.md) |
|
|
216
216
|
|
data/docs/rails.md
CHANGED
|
@@ -6,7 +6,7 @@ The SDK integrates well with Rails applications. Below are the common patterns.
|
|
|
6
6
|
|
|
7
7
|
The SDK depends on [`async`](https://github.com/socketry/async), which installs a Fiber scheduler that multiplexes fibers onto a single OS thread and intercepts IO so blocking calls yield to siblings. Most mature Ruby libraries are thread-safe but not fiber-safe — they key state (checked-out DB connections, per-thread caches, request stores) on `Thread.current`. When the scheduler interleaves two fibers on one thread, those fibers share the same state slot, and interleaved IO on a shared connection silently corrupts wire protocols. This affects every DB driver keyed by thread (`pg`, `mysql2`, `sqlite3`), ActiveRecord's connection pool, and HTTP/cache clients pooled per thread.
|
|
8
8
|
|
|
9
|
-
You do **not** need to think about this.
|
|
9
|
+
You do **not** need to think about this. By default (`callback_scheduling: :thread`; see the fiber-workers section below for the opt-in alternative) the SDK hops to a plain thread at every user-callback boundary — message blocks given to `query` / `Client`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so your code runs with no Fiber scheduler active and inherits the ordinary thread-keyed assumptions every Rails / Sidekiq / Kamal app already makes:
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
12
|
tool = ClaudeAgentSDK.create_tool('lookup_user', 'Look up a user', { id: Integer }) do |args|
|
|
@@ -21,6 +21,67 @@ 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
|
+
|
|
40
|
+
## Fiber workers (solid_queue) and `callback_scheduling: :inline`
|
|
41
|
+
|
|
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:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# config/application.rb
|
|
46
|
+
ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
On Rails 7.2+, ActiveRecord releases connections between queries under fiber isolation, so fiber counts can far exceed the pool size (e.g. 50 fibers on 25 connections).
|
|
50
|
+
|
|
51
|
+
In such a host the default thread hop works *against* you: every callback is ejected from the reactor onto a fresh bare thread, where `Fiber.scheduler` is `nil` (reactor APIs like `Async::Task#stop` / `Async::Notification` are unusable), and an implicitly checked-out AR connection dies with the throwaway thread (stranded until the reaper reclaims it). For these hosts the SDK offers opt-in inline scheduling:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# config/initializers/claude_agent_sdk.rb — process-wide, matching
|
|
55
|
+
# isolation_level's process-wide nature. Only set this in processes that run
|
|
56
|
+
# fiber workers; or pass it per-session via ClaudeAgentOptions instead.
|
|
57
|
+
ClaudeAgentSDK.configure do |config|
|
|
58
|
+
config.default_options = { callback_scheduling: :inline }
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
With `:inline`, every user callback — message blocks, hooks, permission callbacks, SDK MCP handlers, observers — runs in place on the reactor fiber of the job. This is the same execution model as the Python SDK (async callbacks run natively on the event loop). Concretely:
|
|
63
|
+
|
|
64
|
+
- `Fiber.scheduler` is live inside callbacks; reactor primitives work directly. DB access goes through the Rails 7.2+ fiber-aware pool under the same assumptions as the rest of your fiber-worker jobs.
|
|
65
|
+
- No per-call threads exist, so nothing can strand an AR connection.
|
|
66
|
+
- The whole SDK session can live directly on the job fiber — no bridge threads. `Client#connect` already requires an Async context, and the transport's pipe I/O is scheduler-aware.
|
|
67
|
+
- Hook timeouts become **cooperative**: a timed-out inline hook is cancelled at its next suspension point (its `ensure` blocks run), instead of being abandoned on a worker thread. A CPU-stuck hook cannot be timed out.
|
|
68
|
+
- The CLI's cancellation of an in-flight callback (e.g. permission prompt superseded) can now actually interrupt it at a suspension point.
|
|
69
|
+
|
|
70
|
+
The one real risk: **scheduler-opaque blocking stalls the whole reactor.** CPU-bound work or a GVL-holding C extension inside an inline callback blocks every job on that worker, not just yours. Blocking that releases the GVL and pure-Ruby CPU work can be moved onto a thread explicitly:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
tool = ClaudeAgentSDK.create_tool('lookup', 'Query legacy DB', { id: String }) do |args|
|
|
74
|
+
row = ClaudeAgentSDK.offload { legacy_client.fetch(args[:id]) } # plain thread
|
|
75
|
+
{ content: [{ type: 'text', text: row.to_json }] }
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`ClaudeAgentSDK.offload` is a no-op outside a reactor, so it's safe to call unconditionally. Be precise about what it protects, though: it fully shields the reactor from blocking calls that *release* the GVL (native DB drivers, file/socket I/O the scheduler can't see), and it turns pure-Ruby CPU work from a hard stall into GVL time-slicing (added latency for sibling jobs, not starvation). A C extension that **holds** the GVL for the whole computation still freezes the process — `offload` cannot help there; run that work in a subprocess.
|
|
80
|
+
|
|
81
|
+
Preconditions, spelled out: `:inline` is only correct when the process satisfies the same requirements as solid_queue's fiber workers — `isolation_level = :fiber`, Rails 7.2+ for AR, and no thread-keyed libraries used inside callbacks without a fiber-aware wrapper. The SDK warns once if it detects `:inline` under `isolation_level == :thread`. Everything else (Puma request threads, threaded Sidekiq/solid_queue workers) should stay on the default `callback_scheduling: :thread`.
|
|
82
|
+
|
|
83
|
+
Note that `SessionStore` adapter calls (`#append` / `#load`) intentionally stay on threads even in `:inline` mode — their timeouts are hard bounds (`Thread#join`) so a wedged store adapter can never stall the reactor.
|
|
84
|
+
|
|
24
85
|
## ActionCable Streaming
|
|
25
86
|
|
|
26
87
|
Stream Claude responses to the frontend in real-time:
|
|
@@ -24,6 +24,26 @@ module ClaudeAgentSDK
|
|
|
24
24
|
#
|
|
25
25
|
# No-op when no scheduler is active, so it's cheap to use unconditionally.
|
|
26
26
|
#
|
|
27
|
+
# OPT-IN INLINE MODE: hosts that are already fiber-isolated (e.g. Rails
|
|
28
|
+
# apps with `IsolatedExecutionState.isolation_level = :fiber` running
|
|
29
|
+
# solid_queue fiber workers) can pass `scheduling: :inline` to run
|
|
30
|
+
# callbacks in place on the reactor fiber instead of hopping. This is the
|
|
31
|
+
# same code path as the no-scheduler case — semantically the already-
|
|
32
|
+
# shipped synchronous path, and Python SDK parity (async callbacks run
|
|
33
|
+
# natively on the event loop there). The mode is plumbed per-call from
|
|
34
|
+
# `ClaudeAgentOptions#callback_scheduling` — never a thread-local (the
|
|
35
|
+
# reactor thread is shared by many fibers, so a set/reset window across
|
|
36
|
+
# suspension points would leak across sessions). The one path a call
|
|
37
|
+
# argument cannot cross (SDK-MCP dispatch through the mcp gem) carries
|
|
38
|
+
# it via a scoped, invalidatable fiber-storage entry instead — see
|
|
39
|
+
# SCHEDULING_KEY / SchedulingScope below.
|
|
40
|
+
#
|
|
41
|
+
# A `timeout:` always forces the thread hop regardless of scheduling —
|
|
42
|
+
# `Thread#join(timeout)` is a hard bound that can abandon a wedged call,
|
|
43
|
+
# which cooperative `with_timeout` cancellation cannot guarantee. The
|
|
44
|
+
# store-adapter call sites (TranscriptMirrorBatcher, SessionResume) rely
|
|
45
|
+
# on this to never stall the reactor on a wedged adapter.
|
|
46
|
+
#
|
|
27
47
|
# The thread hop severs `break`/`return`/`next` from the surrounding method,
|
|
28
48
|
# so SDK loops yielding user callbacks must keep loop control outside the
|
|
29
49
|
# invoked block (see `Client#receive_response`); user-initiated `break` is
|
|
@@ -44,6 +64,61 @@ module ClaudeAgentSDK
|
|
|
44
64
|
# in-flight call may still complete).
|
|
45
65
|
class JoinTimeout < StandardError; end
|
|
46
66
|
|
|
67
|
+
# Cancellation injected into INLINE user callbacks by timeout
|
|
68
|
+
# enforcement (hook timeouts under scheduling: :inline) — user code
|
|
69
|
+
# should let it propagate. Deliberately
|
|
70
|
+
# NOT a StandardError: the exception is raised inside user code at a
|
|
71
|
+
# suspension point, and a callback's ordinary `rescue StandardError`
|
|
72
|
+
# must not be able to swallow the cancellation and convert an expired
|
|
73
|
+
# hook into a success (Async::TimeoutError is a StandardError, so it
|
|
74
|
+
# cannot be injected directly). The SDK translates it back to
|
|
75
|
+
# Async::TimeoutError once control returns from user code.
|
|
76
|
+
# @api private
|
|
77
|
+
class InlineCancellation < Exception; end # rubocop:disable Lint/InheritException
|
|
78
|
+
|
|
79
|
+
# Fiber-storage key carrying the dispatching session's callback
|
|
80
|
+
# scheduling mode across the SDK-MCP dispatch path (Query ->
|
|
81
|
+
# MCP::Server -> dynamic tool class). Fiber storage is per-fiber, so
|
|
82
|
+
# concurrent sessions with different modes sharing one SdkMcpServer
|
|
83
|
+
# instance cannot cross-contaminate — unlike mutating the shared
|
|
84
|
+
# server (last-writer-wins, persists past close) or a thread-local
|
|
85
|
+
# (the reactor thread is shared by many fibers).
|
|
86
|
+
# @api private
|
|
87
|
+
SCHEDULING_KEY = :claude_agent_sdk_callback_scheduling
|
|
88
|
+
|
|
89
|
+
# The value stored under SCHEDULING_KEY: a
|
|
90
|
+
# closable carrier rather than a bare symbol. Fiber-storage inheritance
|
|
91
|
+
# copies the storage HASH but shares value REFERENCES, so every fiber
|
|
92
|
+
# (and thread) created during a dispatch inherits this same object.
|
|
93
|
+
# Restoring the dispatching fiber's own slot is therefore not enough —
|
|
94
|
+
# a child task spawned inside a handler that outlives the dispatch
|
|
95
|
+
# would keep reading the stale mode forever. Closing the scope in the
|
|
96
|
+
# dispatch's ensure invalidates it for ALL inheritors at once; readers
|
|
97
|
+
# fall back to the server's own default. Benign race on close vs. a
|
|
98
|
+
# concurrent reader: either value is a defensible mode for a call that
|
|
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.
|
|
103
|
+
# @api private
|
|
104
|
+
class SchedulingScope
|
|
105
|
+
attr_reader :mode, :wrapper
|
|
106
|
+
|
|
107
|
+
def initialize(mode, wrapper = nil)
|
|
108
|
+
@mode = mode
|
|
109
|
+
@wrapper = wrapper
|
|
110
|
+
@active = true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def active?
|
|
114
|
+
@active
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def close
|
|
118
|
+
@active = false
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
47
122
|
# Sentinel returned by .invoke_iteration when the user block attempted `break`.
|
|
48
123
|
class Break
|
|
49
124
|
attr_reader :value
|
|
@@ -59,12 +134,38 @@ module ClaudeAgentSDK
|
|
|
59
134
|
# Returns the block's value. Exceptions propagate to the caller.
|
|
60
135
|
#
|
|
61
136
|
# With +timeout+ (seconds) the thread hop happens unconditionally — even
|
|
62
|
-
# without a scheduler — so the bound is
|
|
63
|
-
# too; JoinTimeout is raised when it
|
|
64
|
-
|
|
65
|
-
|
|
137
|
+
# without a scheduler or with `scheduling: :inline` — so the bound is
|
|
138
|
+
# enforced in plain synchronous code too; JoinTimeout is raised when it
|
|
139
|
+
# expires.
|
|
140
|
+
#
|
|
141
|
+
# With `scheduling: :inline` (and no timeout) the block runs in place on
|
|
142
|
+
# the current fiber, scheduler or not. The caller opts in via
|
|
143
|
+
# `ClaudeAgentOptions#callback_scheduling`.
|
|
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)
|
|
66
167
|
|
|
67
|
-
thread = Thread.new(&
|
|
168
|
+
thread = Thread.new(&body)
|
|
68
169
|
thread.report_on_exception = false
|
|
69
170
|
return thread.value if timeout.nil?
|
|
70
171
|
raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout)
|
|
@@ -77,10 +178,17 @@ module ClaudeAgentSDK
|
|
|
77
178
|
# LocalJumpError(reason: :break) on the worker thread; translate it into
|
|
78
179
|
# a Break sentinel so the SDK loop can break on the calling fiber.
|
|
79
180
|
# Returns Break when the user broke, nil when the block completed.
|
|
80
|
-
# Without a scheduler the block runs in
|
|
81
|
-
# natively, never reaching the translation.
|
|
82
|
-
|
|
83
|
-
|
|
181
|
+
# Without a scheduler (or with `scheduling: :inline`) the block runs in
|
|
182
|
+
# place and `break` unwinds natively, never reaching the translation.
|
|
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
|
|
84
192
|
block.call(*args)
|
|
85
193
|
nil
|
|
86
194
|
rescue LocalJumpError => e
|
|
@@ -88,6 +196,8 @@ module ClaudeAgentSDK
|
|
|
88
196
|
|
|
89
197
|
Break.new(e.exit_value)
|
|
90
198
|
end
|
|
199
|
+
body = wrapper ? -> { wrapper.call(invocation) } : invocation
|
|
200
|
+
invoke(scheduling: scheduling) { body.call }
|
|
91
201
|
end
|
|
92
202
|
end
|
|
93
203
|
end
|
|
@@ -63,12 +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)
|
|
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
|
+
@callback_scheduling = callback_scheduling || :thread
|
|
73
|
+
@callback_wrapper = callback_wrapper
|
|
72
74
|
@agents = agents
|
|
73
75
|
@exclude_dynamic_sections = exclude_dynamic_sections
|
|
74
76
|
@skills = skills
|
|
@@ -564,9 +566,12 @@ module ClaudeAgentSDK
|
|
|
564
566
|
description: request_data[:description]
|
|
565
567
|
)
|
|
566
568
|
|
|
567
|
-
# User-supplied permission callback runs on a plain thread
|
|
568
|
-
#
|
|
569
|
-
|
|
569
|
+
# User-supplied permission callback runs on a plain thread by default,
|
|
570
|
+
# so AR/PG calls inside it aren't intercepted by the Fiber scheduler;
|
|
571
|
+
# with callback_scheduling: :inline it runs in place on this control-
|
|
572
|
+
# request task, where control_cancel_request (task.stop) can actually
|
|
573
|
+
# cancel it at suspension points.
|
|
574
|
+
response = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
|
|
570
575
|
@can_use_tool.call(request_data[:tool_name], request_data[:input], context)
|
|
571
576
|
end
|
|
572
577
|
|
|
@@ -602,22 +607,49 @@ module ClaudeAgentSDK
|
|
|
602
607
|
# Create typed HookContext
|
|
603
608
|
context = HookContext.new(signal: nil)
|
|
604
609
|
|
|
605
|
-
# Hop off the Fiber scheduler before invoking user hook code
|
|
606
|
-
#
|
|
607
|
-
# early with an exception and the
|
|
608
|
-
# its own (
|
|
610
|
+
# Hop off the Fiber scheduler before invoking user hook code (default
|
|
611
|
+
# :thread mode). With a timeout, the Async-side with_timeout wraps the
|
|
612
|
+
# hop; if it fires, .value returns early with an exception and the
|
|
613
|
+
# worker thread is left to finish on its own (best-effort abandonment).
|
|
614
|
+
# In :inline mode the callback runs in place, so with_timeout becomes
|
|
615
|
+
# genuine cooperative cancellation: the hook is interrupted at its next
|
|
616
|
+
# suspension point and its ensure blocks run (Python parity — anyio
|
|
617
|
+
# cancels the coroutine). A CPU-stuck inline hook cannot be timed out.
|
|
609
618
|
unless @hook_callback_timeouts[callback_id]
|
|
610
|
-
hook_output = FiberBoundary.invoke do
|
|
619
|
+
hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
|
|
611
620
|
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
612
621
|
end
|
|
613
622
|
end
|
|
614
623
|
|
|
615
624
|
if (timeout = @hook_callback_timeouts[callback_id])
|
|
616
|
-
hook_output =
|
|
617
|
-
|
|
618
|
-
|
|
625
|
+
hook_output =
|
|
626
|
+
if @callback_scheduling == :inline
|
|
627
|
+
# The timeout exception is raised INSIDE user code here, and
|
|
628
|
+
# Async::TimeoutError is a StandardError — a hook's ordinary
|
|
629
|
+
# `rescue StandardError` would swallow the cancellation and
|
|
630
|
+
# convert the expired hook into a success (or keep running past
|
|
631
|
+
# the deadline). Inject a non-StandardError cancellation
|
|
632
|
+
# instead, translated back once control leaves user code so the
|
|
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).
|
|
637
|
+
begin
|
|
638
|
+
Async::Task.current.with_timeout(timeout, FiberBoundary::InlineCancellation) do
|
|
639
|
+
FiberBoundary.invoke(scheduling: :inline, wrapper: @callback_wrapper) do
|
|
640
|
+
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
rescue FiberBoundary::InlineCancellation
|
|
644
|
+
raise Async::TimeoutError, 'execution expired'
|
|
645
|
+
end
|
|
646
|
+
else
|
|
647
|
+
Async::Task.current.with_timeout(timeout) do
|
|
648
|
+
FiberBoundary.invoke(wrapper: @callback_wrapper) do
|
|
649
|
+
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
650
|
+
end
|
|
651
|
+
end
|
|
619
652
|
end
|
|
620
|
-
end
|
|
621
653
|
end
|
|
622
654
|
|
|
623
655
|
# Convert Ruby-safe field names to CLI-expected names
|
|
@@ -966,6 +998,24 @@ module ClaudeAgentSDK
|
|
|
966
998
|
end
|
|
967
999
|
|
|
968
1000
|
def handle_sdk_mcp_request(server_name, message)
|
|
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
|
|
1006
|
+
# storage is per-fiber, so concurrent sessions cannot see each
|
|
1007
|
+
# other's value even across suspension points. The value is a
|
|
1008
|
+
# closable SchedulingScope, closed + restored in the ensure below:
|
|
1009
|
+
# fibers/threads created during the dispatch inherit the same scope
|
|
1010
|
+
# OBJECT (storage inheritance copies the hash, shares references), so
|
|
1011
|
+
# closing it invalidates the mode for every inheritor at once — a
|
|
1012
|
+
# child task that outlives the dispatch cannot carry the session mode
|
|
1013
|
+
# into later direct server calls, and nothing stays stamped on
|
|
1014
|
+
# long-lived fibers.
|
|
1015
|
+
previous_scheduling = Fiber[FiberBoundary::SCHEDULING_KEY]
|
|
1016
|
+
dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling, @callback_wrapper)
|
|
1017
|
+
Fiber[FiberBoundary::SCHEDULING_KEY] = dispatch_scope
|
|
1018
|
+
|
|
969
1019
|
# Convert server_name to symbol if needed for hash lookup
|
|
970
1020
|
server_key = @sdk_mcp_servers.key?(server_name) ? server_name : server_name.to_sym
|
|
971
1021
|
|
|
@@ -1014,6 +1064,9 @@ module ClaudeAgentSDK
|
|
|
1014
1064
|
id: message[:id],
|
|
1015
1065
|
error: { code: -32603, message: e.message }
|
|
1016
1066
|
}
|
|
1067
|
+
ensure
|
|
1068
|
+
dispatch_scope&.close
|
|
1069
|
+
Fiber[FiberBoundary::SCHEDULING_KEY] = previous_scheduling
|
|
1017
1070
|
end
|
|
1018
1071
|
|
|
1019
1072
|
def handle_mcp_initialize(server, message)
|
|
@@ -81,12 +81,66 @@ module ClaudeAgentSDK
|
|
|
81
81
|
class SdkMcpServer
|
|
82
82
|
attr_reader :name, :version, :tools, :resources, :prompts, :mcp_server
|
|
83
83
|
|
|
84
|
+
# Default for where user handlers run when this server is invoked
|
|
85
|
+
# DIRECTLY (call_tool / read_resource / get_prompt outside a session):
|
|
86
|
+
# :thread hops to a plain thread, :inline runs in place. When a session
|
|
87
|
+
# dispatches to this server, the session's own mode arrives via fiber
|
|
88
|
+
# storage instead (see #effective_callback_scheduling) — a server
|
|
89
|
+
# shared by concurrent sessions with different modes is never mutated,
|
|
90
|
+
# so modes cannot cross-contaminate or persist past a session.
|
|
91
|
+
attr_accessor :callback_scheduling
|
|
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
|
+
|
|
100
|
+
# Internal — public only so the dynamic tool classes can reach it. The
|
|
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.
|
|
111
|
+
# @api private
|
|
112
|
+
# @return [Array(Symbol, #call)] `[scheduling, wrapper]`
|
|
113
|
+
def effective_callback_dispatch
|
|
114
|
+
scope = Fiber[FiberBoundary::SCHEDULING_KEY]
|
|
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]
|
|
134
|
+
end
|
|
135
|
+
|
|
84
136
|
def initialize(name:, version: '1.0.0', tools: [], resources: [], prompts: [])
|
|
85
137
|
@name = name
|
|
86
138
|
@version = version
|
|
87
139
|
@tools = tools
|
|
88
140
|
@resources = resources
|
|
89
141
|
@prompts = prompts
|
|
142
|
+
@callback_scheduling = :thread
|
|
143
|
+
@callback_wrapper = nil
|
|
90
144
|
|
|
91
145
|
# Create dynamic Tool classes from tool definitions
|
|
92
146
|
tool_classes = create_tool_classes(tools)
|
|
@@ -171,9 +225,13 @@ module ClaudeAgentSDK
|
|
|
171
225
|
tool = @tools.find { |t| t.name == name }
|
|
172
226
|
return error_tool_result("Tool '#{name}' not found") unless tool
|
|
173
227
|
|
|
174
|
-
# Call the tool's handler on a plain thread so the async
|
|
175
|
-
# Fiber scheduler is not visible to user code (which may hit
|
|
176
|
-
|
|
228
|
+
# Call the tool's handler on a plain thread (default) so the async
|
|
229
|
+
# gem's Fiber scheduler is not visible to user code (which may hit
|
|
230
|
+
# AR/PG); in :inline mode it runs in place on the reactor fiber.
|
|
231
|
+
scheduling, wrapper = effective_callback_dispatch
|
|
232
|
+
result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
233
|
+
tool.handler.call(arguments)
|
|
234
|
+
end
|
|
177
235
|
|
|
178
236
|
# Guard before flexible_fetch: it raises on non-Hash inputs.
|
|
179
237
|
content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "content", "content") : nil
|
|
@@ -208,7 +266,10 @@ module ClaudeAgentSDK
|
|
|
208
266
|
# Hop off the Fiber scheduler before invoking user code — same reason
|
|
209
267
|
# as `call_tool` above: reader blocks may touch Thread.current-keyed
|
|
210
268
|
# libraries (ActiveRecord, pg, ...) and must run on a plain thread.
|
|
211
|
-
|
|
269
|
+
scheduling, wrapper = effective_callback_dispatch
|
|
270
|
+
content = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
271
|
+
resource.reader.call
|
|
272
|
+
end
|
|
212
273
|
|
|
213
274
|
# Ensure content has the expected format (symbol or string keys; guard
|
|
214
275
|
# before flexible_fetch — it raises on non-Hash inputs)
|
|
@@ -240,7 +301,10 @@ module ClaudeAgentSDK
|
|
|
240
301
|
|
|
241
302
|
# Hop off the Fiber scheduler before invoking user code — same reason
|
|
242
303
|
# as `call_tool` above.
|
|
243
|
-
|
|
304
|
+
scheduling, wrapper = effective_callback_dispatch
|
|
305
|
+
result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
306
|
+
prompt.generator.call(arguments)
|
|
307
|
+
end
|
|
244
308
|
|
|
245
309
|
# Ensure result has the expected format (symbol or string keys)
|
|
246
310
|
messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "messages", "messages") : nil
|
|
@@ -281,10 +345,14 @@ module ClaudeAgentSDK
|
|
|
281
345
|
|
|
282
346
|
# Create dynamic Tool classes from tool definitions
|
|
283
347
|
def create_tool_classes(tools)
|
|
348
|
+
# Captured so the dynamic class can resolve the effective scheduling
|
|
349
|
+
# mode at call time — same pattern as prompt classes.
|
|
350
|
+
sdk_server = self
|
|
284
351
|
tools.map do |tool_def|
|
|
285
352
|
# Create a new class that extends MCP::Tool
|
|
286
353
|
Class.new(MCP::Tool) do
|
|
287
354
|
@tool_def = tool_def
|
|
355
|
+
@sdk_server = sdk_server
|
|
288
356
|
|
|
289
357
|
class << self
|
|
290
358
|
attr_reader :tool_def
|
|
@@ -335,8 +403,12 @@ module ClaudeAgentSDK
|
|
|
335
403
|
|
|
336
404
|
def call(server_context: nil, **args)
|
|
337
405
|
# Filter out server_context and pass remaining args to handler.
|
|
338
|
-
# Hop to a plain thread so user handlers don't see
|
|
339
|
-
|
|
406
|
+
# Hop to a plain thread (default) so user handlers don't see
|
|
407
|
+
# the Fiber scheduler; :inline runs in place on the reactor.
|
|
408
|
+
scheduling, wrapper = @sdk_server.effective_callback_dispatch
|
|
409
|
+
result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
410
|
+
@tool_def.handler.call(args)
|
|
411
|
+
end
|
|
340
412
|
|
|
341
413
|
# Guard BEFORE flexible_fetch: on a non-Hash it raises
|
|
342
414
|
# TypeError/NoMethodError, surfacing garbage instead of the
|
|
@@ -1575,7 +1575,8 @@ module ClaudeAgentSDK
|
|
|
1575
1575
|
:session_store, :session_store_flush, :load_timeout_ms
|
|
1576
1576
|
attr_reader :bare, :fork_session, :enable_file_checkpointing,
|
|
1577
1577
|
:include_partial_messages, :continue_conversation,
|
|
1578
|
-
:include_hook_events, :strict_mcp_config
|
|
1578
|
+
:include_hook_events, :strict_mcp_config,
|
|
1579
|
+
:callback_scheduling, :callback_wrapper
|
|
1579
1580
|
|
|
1580
1581
|
def initialize(attributes = {})
|
|
1581
1582
|
self.fork_session = false
|
|
@@ -1598,6 +1599,7 @@ module ClaudeAgentSDK
|
|
|
1598
1599
|
self.session_store_flush ||= 'batched'
|
|
1599
1600
|
# 0 is a valid (immediate) timeout, so only fill in the default for nil.
|
|
1600
1601
|
self.load_timeout_ms = 60_000 if load_timeout_ms.nil?
|
|
1602
|
+
self.callback_scheduling = :thread if callback_scheduling.nil?
|
|
1601
1603
|
end
|
|
1602
1604
|
|
|
1603
1605
|
def dup_with(**changes)
|
|
@@ -1671,6 +1673,55 @@ module ClaudeAgentSDK
|
|
|
1671
1673
|
@strict_mcp_config = coerce_boolean(value)
|
|
1672
1674
|
end
|
|
1673
1675
|
|
|
1676
|
+
CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
|
|
1677
|
+
|
|
1678
|
+
# Where user callbacks (hooks, can_use_tool, SDK MCP handlers, message
|
|
1679
|
+
# blocks, observers) run when the SDK is hosted inside an Async reactor:
|
|
1680
|
+
# :thread (default) — each callback hops to a plain thread, so
|
|
1681
|
+
# thread-keyed libraries (ActiveRecord, pg, ...) behave as usual.
|
|
1682
|
+
# :inline — callbacks run in place on the reactor fiber. Only for
|
|
1683
|
+
# hosts that are fiber-isolated end to end (e.g. solid_queue fiber
|
|
1684
|
+
# workers with IsolatedExecutionState.isolation_level = :fiber).
|
|
1685
|
+
# Scheduler-opaque blocking (CPU-bound work, GVL-holding C
|
|
1686
|
+
# extensions) then stalls the whole reactor — wrap GVL-releasing
|
|
1687
|
+
# blocking and Ruby CPU work in ClaudeAgentSDK.offload { }; work
|
|
1688
|
+
# that holds the GVL throughout needs a subprocess.
|
|
1689
|
+
# Named after the mechanism, not a safety claim: whether inline is safe
|
|
1690
|
+
# depends on the host satisfying the fiber-isolation precondition.
|
|
1691
|
+
def callback_scheduling=(value)
|
|
1692
|
+
if value.nil?
|
|
1693
|
+
@callback_scheduling = nil
|
|
1694
|
+
return
|
|
1695
|
+
end
|
|
1696
|
+
|
|
1697
|
+
mode = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
1698
|
+
unless CALLBACK_SCHEDULING_MODES.include?(mode)
|
|
1699
|
+
raise ArgumentError,
|
|
1700
|
+
"callback_scheduling must be one of #{CALLBACK_SCHEDULING_MODES.map(&:inspect).join(', ')} " \
|
|
1701
|
+
"(got #{value.inspect})"
|
|
1702
|
+
end
|
|
1703
|
+
|
|
1704
|
+
@callback_scheduling = mode
|
|
1705
|
+
end
|
|
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
|
+
|
|
1674
1725
|
private
|
|
1675
1726
|
|
|
1676
1727
|
# Strict key validation: unlike other Type subclasses (which silently drop
|
data/lib/claude_agent_sdk.rb
CHANGED
|
@@ -83,10 +83,10 @@ module ClaudeAgentSDK
|
|
|
83
83
|
# Safely call a method on each observer, suppressing any errors.
|
|
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
|
-
# the SDK's Async reactor.
|
|
87
|
-
def self.notify_observers(observers, method, *args)
|
|
86
|
+
# the SDK's Async reactor — or in place when scheduling is :inline.
|
|
87
|
+
def self.notify_observers(observers, method, *args, scheduling: :thread, wrapper: nil)
|
|
88
88
|
observers.each do |obs|
|
|
89
|
-
FiberBoundary.invoke { 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
|
|
@@ -95,6 +95,45 @@ module ClaudeAgentSDK
|
|
|
95
95
|
end
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
+
# Public escape hatch for hosts running with callback_scheduling: :inline:
|
|
99
|
+
# run a heavy piece of a callback on a plain thread instead of the shared
|
|
100
|
+
# reactor fiber. What that buys, precisely:
|
|
101
|
+
# - scheduler-opaque BLOCKING that releases the GVL (native DB drivers,
|
|
102
|
+
# file/socket calls the scheduler can't see): the reactor keeps running.
|
|
103
|
+
# - pure-Ruby CPU-bound work: degrades a hard reactor stall into GVL
|
|
104
|
+
# time-slicing — added latency for other fibers, not starvation.
|
|
105
|
+
# - a C extension that HOLDS the GVL for the whole computation: no help;
|
|
106
|
+
# nothing in-process can protect the reactor from that — move such work
|
|
107
|
+
# to a subprocess.
|
|
108
|
+
# No-op outside a Fiber scheduler, so it is safe to call unconditionally.
|
|
109
|
+
# Returns the block's value; exceptions propagate.
|
|
110
|
+
#
|
|
111
|
+
# @example Inside an inline-mode tool handler
|
|
112
|
+
# ClaudeAgentSDK.offload { blocking_db_call }
|
|
113
|
+
def self.offload(&block)
|
|
114
|
+
FiberBoundary.invoke(&block)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Internal: warn once per process when :inline callback scheduling is
|
|
118
|
+
# enabled while ActiveSupport reports thread isolation — the host then
|
|
119
|
+
# almost certainly violates inline mode's fiber-isolation precondition
|
|
120
|
+
# (solid_queue fiber workers require isolation_level = :fiber).
|
|
121
|
+
# defined? probing only; the SDK never loads ActiveSupport itself.
|
|
122
|
+
def self.check_inline_isolation(scheduling)
|
|
123
|
+
return unless scheduling == :inline
|
|
124
|
+
return if @inline_isolation_warned
|
|
125
|
+
return unless defined?(ActiveSupport::IsolatedExecutionState)
|
|
126
|
+
return unless ActiveSupport::IsolatedExecutionState.isolation_level == :thread
|
|
127
|
+
|
|
128
|
+
@inline_isolation_warned = true
|
|
129
|
+
warn 'ClaudeAgentSDK: callback_scheduling: :inline is enabled but ' \
|
|
130
|
+
'ActiveSupport::IsolatedExecutionState.isolation_level is :thread. ' \
|
|
131
|
+
'Inline callbacks run on reactor fibers that share one thread, so ' \
|
|
132
|
+
'thread-keyed Rails state will leak across fibers. Set ' \
|
|
133
|
+
'isolation_level = :fiber (as solid_queue fiber workers require) ' \
|
|
134
|
+
'or use the default callback_scheduling: :thread.'
|
|
135
|
+
end
|
|
136
|
+
|
|
98
137
|
# Extract the user-visible prompt text from a streamed input item, or nil
|
|
99
138
|
# when there is none (non-user messages, tool_result-only content, …).
|
|
100
139
|
# Only Hash and JSON-string items are inspected; arbitrary objects written
|
|
@@ -148,13 +187,13 @@ module ClaudeAgentSDK
|
|
|
148
187
|
# Wrap a streaming-input enumerable so observers get on_user_prompt for
|
|
149
188
|
# each user message before it is written to stdin. Identity when no
|
|
150
189
|
# observers are configured.
|
|
151
|
-
def self.observing_prompt_stream(prompt, observers)
|
|
190
|
+
def self.observing_prompt_stream(prompt, observers, scheduling: :thread, wrapper: nil)
|
|
152
191
|
return prompt if observers.empty?
|
|
153
192
|
|
|
154
193
|
Enumerator.new do |yielder|
|
|
155
194
|
prompt.each do |message|
|
|
156
195
|
text = extract_user_prompt_text(message)
|
|
157
|
-
notify_observers(observers, :on_user_prompt, text) if text
|
|
196
|
+
notify_observers(observers, :on_user_prompt, text, scheduling: scheduling, wrapper: wrapper) if text
|
|
158
197
|
yielder << message
|
|
159
198
|
end
|
|
160
199
|
end
|
|
@@ -422,6 +461,12 @@ module ClaudeAgentSDK
|
|
|
422
461
|
# Resolve callable observers into fresh instances (thread-safe for global defaults)
|
|
423
462
|
resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers)
|
|
424
463
|
|
|
464
|
+
# Where user callbacks run (see ClaudeAgentOptions#callback_scheduling)
|
|
465
|
+
# and the middleware wrapped around them (#callback_wrapper).
|
|
466
|
+
callback_scheduling = configured_options.callback_scheduling || :thread
|
|
467
|
+
callback_wrapper = configured_options.callback_wrapper
|
|
468
|
+
ClaudeAgentSDK.check_inline_isolation(callback_scheduling)
|
|
469
|
+
|
|
425
470
|
raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' if transport && !transport.respond_to?(:connect)
|
|
426
471
|
|
|
427
472
|
Async do
|
|
@@ -481,7 +526,9 @@ module ClaudeAgentSDK
|
|
|
481
526
|
agents: configured_options.agents,
|
|
482
527
|
sdk_mcp_servers: sdk_mcp_servers,
|
|
483
528
|
exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
|
|
484
|
-
skills: configured_options.skills
|
|
529
|
+
skills: configured_options.skills,
|
|
530
|
+
callback_scheduling: callback_scheduling,
|
|
531
|
+
callback_wrapper: callback_wrapper
|
|
485
532
|
)
|
|
486
533
|
|
|
487
534
|
# Mirror transcripts to the session_store, if configured. Installed
|
|
@@ -505,7 +552,8 @@ module ClaudeAgentSDK
|
|
|
505
552
|
|
|
506
553
|
# Send prompt(s) as user messages, then close stdin
|
|
507
554
|
if prompt.is_a?(String)
|
|
508
|
-
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)
|
|
509
557
|
message = {
|
|
510
558
|
type: 'user',
|
|
511
559
|
message: { role: 'user', content: prompt },
|
|
@@ -523,19 +571,23 @@ module ClaudeAgentSDK
|
|
|
523
571
|
# here kept the root reactor alive forever when the read loop died
|
|
524
572
|
# while the user enumerator was still blocked (matches Python's
|
|
525
573
|
# query.spawn_task(query.stream_input(prompt))).
|
|
526
|
-
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)
|
|
527
576
|
query_handler.spawn_task { query_handler.stream_input(observed_prompt) }
|
|
528
577
|
end
|
|
529
578
|
|
|
530
579
|
# Read and yield messages from the query handler (filters out control messages).
|
|
531
580
|
# User block is invoked through FiberBoundary so ActiveRecord / PG calls
|
|
532
|
-
# inside it don't see the async gem's Fiber scheduler
|
|
581
|
+
# inside it don't see the async gem's Fiber scheduler (default :thread
|
|
582
|
+
# mode; :inline runs it in place on the reactor fiber).
|
|
533
583
|
query_handler.receive_messages do |data|
|
|
534
584
|
message = MessageParser.parse(data)
|
|
535
585
|
next unless message
|
|
536
586
|
|
|
537
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message
|
|
538
|
-
|
|
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)
|
|
539
591
|
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
540
592
|
end
|
|
541
593
|
rescue StandardError => e
|
|
@@ -544,10 +596,12 @@ module ClaudeAgentSDK
|
|
|
544
596
|
# parse errors, and user-block errors. StandardError only: Async::Stop
|
|
545
597
|
# is cancellation, not an error. Bare raise preserves the backtrace;
|
|
546
598
|
# the ensure below still fires on_close after on_error.
|
|
547
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e
|
|
599
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e,
|
|
600
|
+
scheduling: callback_scheduling, wrapper: callback_wrapper)
|
|
548
601
|
raise
|
|
549
602
|
ensure
|
|
550
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_close
|
|
603
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_close,
|
|
604
|
+
scheduling: callback_scheduling, wrapper: callback_wrapper)
|
|
551
605
|
# query_handler.close stops the background read task and closes the
|
|
552
606
|
# transport (flushing the mirror batcher first). Fall back to a bare
|
|
553
607
|
# transport close when the handler was never built.
|
|
@@ -617,6 +671,8 @@ module ClaudeAgentSDK
|
|
|
617
671
|
# @param transport_args [Hash] Additional keyword arguments passed to transport_class.new(options, **transport_args)
|
|
618
672
|
def initialize(options: nil, transport_class: SubprocessCLITransport, transport_args: {})
|
|
619
673
|
@options = options || ClaudeAgentOptions.new
|
|
674
|
+
@callback_scheduling = @options.callback_scheduling || :thread
|
|
675
|
+
@callback_wrapper = @options.callback_wrapper
|
|
620
676
|
@transport_class = transport_class
|
|
621
677
|
@transport_args = transport_args
|
|
622
678
|
@transport = nil
|
|
@@ -699,6 +755,8 @@ module ClaudeAgentSDK
|
|
|
699
755
|
# notified via on_error.
|
|
700
756
|
@resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
|
|
701
757
|
|
|
758
|
+
ClaudeAgentSDK.check_inline_isolation(@callback_scheduling)
|
|
759
|
+
|
|
702
760
|
# If anything from materialization onward fails, tear down (closes the
|
|
703
761
|
# subprocess and removes the materialized temp config dir) before
|
|
704
762
|
# surfacing the error, so a partial connect never leaks a temp dir
|
|
@@ -751,7 +809,8 @@ module ClaudeAgentSDK
|
|
|
751
809
|
|
|
752
810
|
begin
|
|
753
811
|
if prompt.is_a?(String)
|
|
754
|
-
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)
|
|
755
814
|
message = {
|
|
756
815
|
type: 'user',
|
|
757
816
|
message: { role: 'user', content: prompt },
|
|
@@ -792,8 +851,10 @@ module ClaudeAgentSDK
|
|
|
792
851
|
message = MessageParser.parse(data)
|
|
793
852
|
next unless message
|
|
794
853
|
|
|
795
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message
|
|
796
|
-
|
|
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)
|
|
797
858
|
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
798
859
|
end
|
|
799
860
|
rescue StandardError => e
|
|
@@ -818,8 +879,10 @@ module ClaudeAgentSDK
|
|
|
818
879
|
message = MessageParser.parse(data)
|
|
819
880
|
next unless message
|
|
820
881
|
|
|
821
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message
|
|
822
|
-
|
|
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)
|
|
823
886
|
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
824
887
|
break if message.is_a?(ResultMessage)
|
|
825
888
|
end
|
|
@@ -911,7 +974,10 @@ module ClaudeAgentSDK
|
|
|
911
974
|
|
|
912
975
|
# Disconnect from Claude
|
|
913
976
|
def disconnect
|
|
914
|
-
|
|
977
|
+
if @connected
|
|
978
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close,
|
|
979
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
980
|
+
end
|
|
915
981
|
# Tear down whatever exists — robust to a partial/failed connect, where
|
|
916
982
|
# @connected is still false but a transport and/or materialized temp dir
|
|
917
983
|
# were already created. #close on the query handler also closes the
|
|
@@ -997,7 +1063,9 @@ module ClaudeAgentSDK
|
|
|
997
1063
|
sdk_mcp_servers: sdk_mcp_servers,
|
|
998
1064
|
agents: configured_options.agents,
|
|
999
1065
|
exclude_dynamic_sections: exclude_dynamic_sections,
|
|
1000
|
-
skills: configured_options.skills
|
|
1066
|
+
skills: configured_options.skills,
|
|
1067
|
+
callback_scheduling: @callback_scheduling,
|
|
1068
|
+
callback_wrapper: @callback_wrapper
|
|
1001
1069
|
)
|
|
1002
1070
|
|
|
1003
1071
|
# Mirror transcripts to the session_store, if configured.
|
|
@@ -1028,7 +1096,8 @@ module ClaudeAgentSDK
|
|
|
1028
1096
|
# Observer#on_error contract; notifying a swallowed error would mark
|
|
1029
1097
|
# a still-live OTel trace as failed). Same behavior as query()'s
|
|
1030
1098
|
# streaming path.
|
|
1031
|
-
observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers
|
|
1099
|
+
observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers,
|
|
1100
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
1032
1101
|
@query_handler.spawn_task { @query_handler.stream_input(observed) }
|
|
1033
1102
|
end
|
|
1034
1103
|
end
|
|
@@ -1045,12 +1114,14 @@ module ClaudeAgentSDK
|
|
|
1045
1114
|
when Hash
|
|
1046
1115
|
msg = msg.merge(session_id: session_id) unless msg.key?(:session_id) || msg.key?('session_id')
|
|
1047
1116
|
if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
|
|
1048
|
-
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)
|
|
1049
1119
|
end
|
|
1050
1120
|
writeln(JSON.generate(msg))
|
|
1051
1121
|
when String
|
|
1052
1122
|
if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
|
|
1053
|
-
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)
|
|
1054
1125
|
end
|
|
1055
1126
|
writeln(msg)
|
|
1056
1127
|
else
|
|
@@ -1064,7 +1135,8 @@ module ClaudeAgentSDK
|
|
|
1064
1135
|
# Notify observers of an error surfacing to the consumer. `|| []` keeps a
|
|
1065
1136
|
# mis-scoped call before connect harmless instead of NoMethodError on nil.
|
|
1066
1137
|
def notify_error(error)
|
|
1067
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error
|
|
1138
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error,
|
|
1139
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
1068
1140
|
end
|
|
1069
1141
|
|
|
1070
1142
|
# Build and install the transcript-mirror batcher on the query handler when
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: claude-agent-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.26.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Community Contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-31 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: async
|