claude-agent-sdk 0.24.0 → 0.25.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 +6 -0
- data/README.md +2 -2
- data/docs/rails.md +46 -1
- data/lib/claude_agent_sdk/fiber_boundary.rb +84 -8
- data/lib/claude_agent_sdk/query.rb +61 -13
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +38 -7
- data/lib/claude_agent_sdk/types.rb +34 -1
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +73 -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: 48fe05a71686f61555d5985ac8fe92e5d08b1ac530f1c2e2bb071f4d6b0fdb81
|
|
4
|
+
data.tar.gz: 669eedb9627e578e893e3e214271c172a0169d07fd9f60493c5c2d47c5540acb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7efcdba28fd54e81ddbf4b24ae01e14fde11c9c111f39241f65901eb23453d938e4e1be0fee3493fc034f0e81f0ec8373664d68a061d029b280e01f2a7ed4ad0
|
|
7
|
+
data.tar.gz: 2fb7a50378cb0bd44408b2d31737bd4fc1ec51162473e1417dcd1be58c851b5e369c9de9313edfa48425a5e36d058ccade91cf47bf5681251eeb9fa3c2a89031
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.25.0] - 2026-07-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **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.
|
|
14
|
+
- `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.
|
|
15
|
+
|
|
10
16
|
## [0.24.0] - 2026-07-27
|
|
11
17
|
|
|
12
18
|
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.25.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,51 @@ 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
|
+
## Fiber workers (solid_queue) and `callback_scheduling: :inline`
|
|
25
|
+
|
|
26
|
+
[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:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# config/application.rb
|
|
30
|
+
ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
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).
|
|
34
|
+
|
|
35
|
+
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:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# config/initializers/claude_agent_sdk.rb — process-wide, matching
|
|
39
|
+
# isolation_level's process-wide nature. Only set this in processes that run
|
|
40
|
+
# fiber workers; or pass it per-session via ClaudeAgentOptions instead.
|
|
41
|
+
ClaudeAgentSDK.configure do |config|
|
|
42
|
+
config.default_options = { callback_scheduling: :inline }
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
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:
|
|
47
|
+
|
|
48
|
+
- `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.
|
|
49
|
+
- No per-call threads exist, so nothing can strand an AR connection.
|
|
50
|
+
- 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.
|
|
51
|
+
- 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.
|
|
52
|
+
- The CLI's cancellation of an in-flight callback (e.g. permission prompt superseded) can now actually interrupt it at a suspension point.
|
|
53
|
+
|
|
54
|
+
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:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
tool = ClaudeAgentSDK.create_tool('lookup', 'Query legacy DB', { id: String }) do |args|
|
|
58
|
+
row = ClaudeAgentSDK.offload { legacy_client.fetch(args[:id]) } # plain thread
|
|
59
|
+
{ content: [{ type: 'text', text: row.to_json }] }
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`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.
|
|
64
|
+
|
|
65
|
+
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`.
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
24
69
|
## ActionCable Streaming
|
|
25
70
|
|
|
26
71
|
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,57 @@ 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
|
+
# @api private
|
|
101
|
+
class SchedulingScope
|
|
102
|
+
attr_reader :mode
|
|
103
|
+
|
|
104
|
+
def initialize(mode)
|
|
105
|
+
@mode = mode
|
|
106
|
+
@active = true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def active?
|
|
110
|
+
@active
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def close
|
|
114
|
+
@active = false
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
47
118
|
# Sentinel returned by .invoke_iteration when the user block attempted `break`.
|
|
48
119
|
class Break
|
|
49
120
|
attr_reader :value
|
|
@@ -59,10 +130,15 @@ module ClaudeAgentSDK
|
|
|
59
130
|
# Returns the block's value. Exceptions propagate to the caller.
|
|
60
131
|
#
|
|
61
132
|
# 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
|
-
|
|
133
|
+
# without a scheduler or with `scheduling: :inline` — so the bound is
|
|
134
|
+
# enforced in plain synchronous code too; JoinTimeout is raised when it
|
|
135
|
+
# expires.
|
|
136
|
+
#
|
|
137
|
+
# With `scheduling: :inline` (and no timeout) the block runs in place on
|
|
138
|
+
# the current fiber, scheduler or not. The caller opts in via
|
|
139
|
+
# `ClaudeAgentOptions#callback_scheduling`.
|
|
140
|
+
def invoke(timeout: nil, scheduling: :thread, &block)
|
|
141
|
+
return block.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler)
|
|
66
142
|
|
|
67
143
|
thread = Thread.new(&block)
|
|
68
144
|
thread.report_on_exception = false
|
|
@@ -77,10 +153,10 @@ module ClaudeAgentSDK
|
|
|
77
153
|
# LocalJumpError(reason: :break) on the worker thread; translate it into
|
|
78
154
|
# a Break sentinel so the SDK loop can break on the calling fiber.
|
|
79
155
|
# 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
|
-
def invoke_iteration(block, *args)
|
|
83
|
-
invoke do
|
|
156
|
+
# Without a scheduler (or with `scheduling: :inline`) the block runs in
|
|
157
|
+
# place and `break` unwinds natively, never reaching the translation.
|
|
158
|
+
def invoke_iteration(block, *args, scheduling: :thread)
|
|
159
|
+
invoke(scheduling: scheduling) do
|
|
84
160
|
block.call(*args)
|
|
85
161
|
nil
|
|
86
162
|
rescue LocalJumpError => e
|
|
@@ -63,12 +63,13 @@ 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)
|
|
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
|
|
72
73
|
@agents = agents
|
|
73
74
|
@exclude_dynamic_sections = exclude_dynamic_sections
|
|
74
75
|
@skills = skills
|
|
@@ -564,9 +565,12 @@ module ClaudeAgentSDK
|
|
|
564
565
|
description: request_data[:description]
|
|
565
566
|
)
|
|
566
567
|
|
|
567
|
-
# User-supplied permission callback runs on a plain thread
|
|
568
|
-
#
|
|
569
|
-
|
|
568
|
+
# User-supplied permission callback runs on a plain thread by default,
|
|
569
|
+
# so AR/PG calls inside it aren't intercepted by the Fiber scheduler;
|
|
570
|
+
# with callback_scheduling: :inline it runs in place on this control-
|
|
571
|
+
# request task, where control_cancel_request (task.stop) can actually
|
|
572
|
+
# cancel it at suspension points.
|
|
573
|
+
response = FiberBoundary.invoke(scheduling: @callback_scheduling) do
|
|
570
574
|
@can_use_tool.call(request_data[:tool_name], request_data[:input], context)
|
|
571
575
|
end
|
|
572
576
|
|
|
@@ -602,22 +606,46 @@ module ClaudeAgentSDK
|
|
|
602
606
|
# Create typed HookContext
|
|
603
607
|
context = HookContext.new(signal: nil)
|
|
604
608
|
|
|
605
|
-
# Hop off the Fiber scheduler before invoking user hook code
|
|
606
|
-
#
|
|
607
|
-
# early with an exception and the
|
|
608
|
-
# its own (
|
|
609
|
+
# Hop off the Fiber scheduler before invoking user hook code (default
|
|
610
|
+
# :thread mode). With a timeout, the Async-side with_timeout wraps the
|
|
611
|
+
# hop; if it fires, .value returns early with an exception and the
|
|
612
|
+
# worker thread is left to finish on its own (best-effort abandonment).
|
|
613
|
+
# In :inline mode the callback runs in place, so with_timeout becomes
|
|
614
|
+
# genuine cooperative cancellation: the hook is interrupted at its next
|
|
615
|
+
# suspension point and its ensure blocks run (Python parity — anyio
|
|
616
|
+
# cancels the coroutine). A CPU-stuck inline hook cannot be timed out.
|
|
609
617
|
unless @hook_callback_timeouts[callback_id]
|
|
610
|
-
hook_output = FiberBoundary.invoke do
|
|
618
|
+
hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling) do
|
|
611
619
|
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
612
620
|
end
|
|
613
621
|
end
|
|
614
622
|
|
|
615
623
|
if (timeout = @hook_callback_timeouts[callback_id])
|
|
616
|
-
hook_output =
|
|
617
|
-
|
|
618
|
-
|
|
624
|
+
hook_output =
|
|
625
|
+
if @callback_scheduling == :inline
|
|
626
|
+
# The timeout exception is raised INSIDE user code here, and
|
|
627
|
+
# Async::TimeoutError is a StandardError — a hook's ordinary
|
|
628
|
+
# `rescue StandardError` would swallow the cancellation and
|
|
629
|
+
# convert the expired hook into a success (or keep running past
|
|
630
|
+
# the deadline). Inject a non-StandardError cancellation
|
|
631
|
+
# instead, translated back once control leaves user code so the
|
|
632
|
+
# outward contract (Async::TimeoutError) is unchanged.
|
|
633
|
+
begin
|
|
634
|
+
Async::Task.current.with_timeout(timeout, FiberBoundary::InlineCancellation) do
|
|
635
|
+
FiberBoundary.invoke(scheduling: :inline) do
|
|
636
|
+
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
rescue FiberBoundary::InlineCancellation
|
|
640
|
+
raise Async::TimeoutError, 'execution expired'
|
|
641
|
+
end
|
|
642
|
+
else
|
|
643
|
+
Async::Task.current.with_timeout(timeout) do
|
|
644
|
+
FiberBoundary.invoke do
|
|
645
|
+
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
619
648
|
end
|
|
620
|
-
end
|
|
621
649
|
end
|
|
622
650
|
|
|
623
651
|
# Convert Ruby-safe field names to CLI-expected names
|
|
@@ -966,6 +994,23 @@ module ClaudeAgentSDK
|
|
|
966
994
|
end
|
|
967
995
|
|
|
968
996
|
def handle_sdk_mcp_request(server_name, message)
|
|
997
|
+
# Carry this session's scheduling mode across the dispatch into the
|
|
998
|
+
# (possibly session-shared) SdkMcpServer via fiber storage — set on
|
|
999
|
+
# the dispatching fiber, read back by the server's handlers at invoke
|
|
1000
|
+
# time (see SdkMcpServer#effective_callback_scheduling). Fiber
|
|
1001
|
+
# storage is per-fiber, so concurrent sessions cannot see each
|
|
1002
|
+
# other's value even across suspension points. The value is a
|
|
1003
|
+
# closable SchedulingScope, closed + restored in the ensure below:
|
|
1004
|
+
# fibers/threads created during the dispatch inherit the same scope
|
|
1005
|
+
# OBJECT (storage inheritance copies the hash, shares references), so
|
|
1006
|
+
# closing it invalidates the mode for every inheritor at once — a
|
|
1007
|
+
# child task that outlives the dispatch cannot carry the session mode
|
|
1008
|
+
# into later direct server calls, and nothing stays stamped on
|
|
1009
|
+
# long-lived fibers.
|
|
1010
|
+
previous_scheduling = Fiber[FiberBoundary::SCHEDULING_KEY]
|
|
1011
|
+
dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling)
|
|
1012
|
+
Fiber[FiberBoundary::SCHEDULING_KEY] = dispatch_scope
|
|
1013
|
+
|
|
969
1014
|
# Convert server_name to symbol if needed for hash lookup
|
|
970
1015
|
server_key = @sdk_mcp_servers.key?(server_name) ? server_name : server_name.to_sym
|
|
971
1016
|
|
|
@@ -1014,6 +1059,9 @@ module ClaudeAgentSDK
|
|
|
1014
1059
|
id: message[:id],
|
|
1015
1060
|
error: { code: -32603, message: e.message }
|
|
1016
1061
|
}
|
|
1062
|
+
ensure
|
|
1063
|
+
dispatch_scope&.close
|
|
1064
|
+
Fiber[FiberBoundary::SCHEDULING_KEY] = previous_scheduling
|
|
1017
1065
|
end
|
|
1018
1066
|
|
|
1019
1067
|
def handle_mcp_initialize(server, message)
|
|
@@ -81,12 +81,35 @@ 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
|
+
# Internal — public only so the dynamic tool classes can reach it. The
|
|
94
|
+
# scheduling mode for the current invocation: the dispatching session's
|
|
95
|
+
# mode (a live SchedulingScope in fiber storage, set by Query around
|
|
96
|
+
# the dispatch) when present, else this server's own default. A scope
|
|
97
|
+
# inherited from an already-finished dispatch is closed and
|
|
98
|
+
# deliberately ignored — a child task spawned inside a handler must not
|
|
99
|
+
# carry the session mode into later direct calls.
|
|
100
|
+
# @api private
|
|
101
|
+
def effective_callback_scheduling
|
|
102
|
+
scope = Fiber[FiberBoundary::SCHEDULING_KEY]
|
|
103
|
+
scope&.active? ? scope.mode : @callback_scheduling
|
|
104
|
+
end
|
|
105
|
+
|
|
84
106
|
def initialize(name:, version: '1.0.0', tools: [], resources: [], prompts: [])
|
|
85
107
|
@name = name
|
|
86
108
|
@version = version
|
|
87
109
|
@tools = tools
|
|
88
110
|
@resources = resources
|
|
89
111
|
@prompts = prompts
|
|
112
|
+
@callback_scheduling = :thread
|
|
90
113
|
|
|
91
114
|
# Create dynamic Tool classes from tool definitions
|
|
92
115
|
tool_classes = create_tool_classes(tools)
|
|
@@ -171,9 +194,10 @@ module ClaudeAgentSDK
|
|
|
171
194
|
tool = @tools.find { |t| t.name == name }
|
|
172
195
|
return error_tool_result("Tool '#{name}' not found") unless tool
|
|
173
196
|
|
|
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
|
-
|
|
197
|
+
# Call the tool's handler on a plain thread (default) so the async
|
|
198
|
+
# gem's Fiber scheduler is not visible to user code (which may hit
|
|
199
|
+
# AR/PG); in :inline mode it runs in place on the reactor fiber.
|
|
200
|
+
result = FiberBoundary.invoke(scheduling: effective_callback_scheduling) { tool.handler.call(arguments) }
|
|
177
201
|
|
|
178
202
|
# Guard before flexible_fetch: it raises on non-Hash inputs.
|
|
179
203
|
content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "content", "content") : nil
|
|
@@ -208,7 +232,7 @@ module ClaudeAgentSDK
|
|
|
208
232
|
# Hop off the Fiber scheduler before invoking user code — same reason
|
|
209
233
|
# as `call_tool` above: reader blocks may touch Thread.current-keyed
|
|
210
234
|
# libraries (ActiveRecord, pg, ...) and must run on a plain thread.
|
|
211
|
-
content = FiberBoundary.invoke { resource.reader.call }
|
|
235
|
+
content = FiberBoundary.invoke(scheduling: effective_callback_scheduling) { resource.reader.call }
|
|
212
236
|
|
|
213
237
|
# Ensure content has the expected format (symbol or string keys; guard
|
|
214
238
|
# before flexible_fetch — it raises on non-Hash inputs)
|
|
@@ -240,7 +264,7 @@ module ClaudeAgentSDK
|
|
|
240
264
|
|
|
241
265
|
# Hop off the Fiber scheduler before invoking user code — same reason
|
|
242
266
|
# as `call_tool` above.
|
|
243
|
-
result = FiberBoundary.invoke { prompt.generator.call(arguments) }
|
|
267
|
+
result = FiberBoundary.invoke(scheduling: effective_callback_scheduling) { prompt.generator.call(arguments) }
|
|
244
268
|
|
|
245
269
|
# Ensure result has the expected format (symbol or string keys)
|
|
246
270
|
messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "messages", "messages") : nil
|
|
@@ -281,10 +305,14 @@ module ClaudeAgentSDK
|
|
|
281
305
|
|
|
282
306
|
# Create dynamic Tool classes from tool definitions
|
|
283
307
|
def create_tool_classes(tools)
|
|
308
|
+
# Captured so the dynamic class can resolve the effective scheduling
|
|
309
|
+
# mode at call time — same pattern as prompt classes.
|
|
310
|
+
sdk_server = self
|
|
284
311
|
tools.map do |tool_def|
|
|
285
312
|
# Create a new class that extends MCP::Tool
|
|
286
313
|
Class.new(MCP::Tool) do
|
|
287
314
|
@tool_def = tool_def
|
|
315
|
+
@sdk_server = sdk_server
|
|
288
316
|
|
|
289
317
|
class << self
|
|
290
318
|
attr_reader :tool_def
|
|
@@ -335,8 +363,11 @@ module ClaudeAgentSDK
|
|
|
335
363
|
|
|
336
364
|
def call(server_context: nil, **args)
|
|
337
365
|
# Filter out server_context and pass remaining args to handler.
|
|
338
|
-
# Hop to a plain thread so user handlers don't see
|
|
339
|
-
|
|
366
|
+
# Hop to a plain thread (default) so user handlers don't see
|
|
367
|
+
# the Fiber scheduler; :inline runs in place on the reactor.
|
|
368
|
+
result = FiberBoundary.invoke(scheduling: @sdk_server.effective_callback_scheduling) do
|
|
369
|
+
@tool_def.handler.call(args)
|
|
370
|
+
end
|
|
340
371
|
|
|
341
372
|
# Guard BEFORE flexible_fetch: on a non-Hash it raises
|
|
342
373
|
# 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
|
|
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,37 @@ 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
|
+
|
|
1674
1707
|
private
|
|
1675
1708
|
|
|
1676
1709
|
# 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)
|
|
88
88
|
observers.each do |obs|
|
|
89
|
-
FiberBoundary.invoke { obs.send(method, *args) }
|
|
89
|
+
FiberBoundary.invoke(scheduling: scheduling) { 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)
|
|
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) if text
|
|
158
197
|
yielder << message
|
|
159
198
|
end
|
|
160
199
|
end
|
|
@@ -422,6 +461,10 @@ 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
|
+
callback_scheduling = configured_options.callback_scheduling || :thread
|
|
466
|
+
ClaudeAgentSDK.check_inline_isolation(callback_scheduling)
|
|
467
|
+
|
|
425
468
|
raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' if transport && !transport.respond_to?(:connect)
|
|
426
469
|
|
|
427
470
|
Async do
|
|
@@ -481,7 +524,8 @@ module ClaudeAgentSDK
|
|
|
481
524
|
agents: configured_options.agents,
|
|
482
525
|
sdk_mcp_servers: sdk_mcp_servers,
|
|
483
526
|
exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
|
|
484
|
-
skills: configured_options.skills
|
|
527
|
+
skills: configured_options.skills,
|
|
528
|
+
callback_scheduling: callback_scheduling
|
|
485
529
|
)
|
|
486
530
|
|
|
487
531
|
# Mirror transcripts to the session_store, if configured. Installed
|
|
@@ -505,7 +549,7 @@ module ClaudeAgentSDK
|
|
|
505
549
|
|
|
506
550
|
# Send prompt(s) as user messages, then close stdin
|
|
507
551
|
if prompt.is_a?(String)
|
|
508
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt)
|
|
552
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt, scheduling: callback_scheduling)
|
|
509
553
|
message = {
|
|
510
554
|
type: 'user',
|
|
511
555
|
message: { role: 'user', content: prompt },
|
|
@@ -523,19 +567,20 @@ module ClaudeAgentSDK
|
|
|
523
567
|
# here kept the root reactor alive forever when the read loop died
|
|
524
568
|
# while the user enumerator was still blocked (matches Python's
|
|
525
569
|
# query.spawn_task(query.stream_input(prompt))).
|
|
526
|
-
observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers)
|
|
570
|
+
observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers, scheduling: callback_scheduling)
|
|
527
571
|
query_handler.spawn_task { query_handler.stream_input(observed_prompt) }
|
|
528
572
|
end
|
|
529
573
|
|
|
530
574
|
# Read and yield messages from the query handler (filters out control messages).
|
|
531
575
|
# User block is invoked through FiberBoundary so ActiveRecord / PG calls
|
|
532
|
-
# inside it don't see the async gem's Fiber scheduler
|
|
576
|
+
# inside it don't see the async gem's Fiber scheduler (default :thread
|
|
577
|
+
# mode; :inline runs it in place on the reactor fiber).
|
|
533
578
|
query_handler.receive_messages do |data|
|
|
534
579
|
message = MessageParser.parse(data)
|
|
535
580
|
next unless message
|
|
536
581
|
|
|
537
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message)
|
|
538
|
-
signal = FiberBoundary.invoke_iteration(block, message)
|
|
582
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message, scheduling: callback_scheduling)
|
|
583
|
+
signal = FiberBoundary.invoke_iteration(block, message, scheduling: callback_scheduling)
|
|
539
584
|
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
540
585
|
end
|
|
541
586
|
rescue StandardError => e
|
|
@@ -544,10 +589,10 @@ module ClaudeAgentSDK
|
|
|
544
589
|
# parse errors, and user-block errors. StandardError only: Async::Stop
|
|
545
590
|
# is cancellation, not an error. Bare raise preserves the backtrace;
|
|
546
591
|
# the ensure below still fires on_close after on_error.
|
|
547
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e)
|
|
592
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e, scheduling: callback_scheduling)
|
|
548
593
|
raise
|
|
549
594
|
ensure
|
|
550
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_close)
|
|
595
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_close, scheduling: callback_scheduling)
|
|
551
596
|
# query_handler.close stops the background read task and closes the
|
|
552
597
|
# transport (flushing the mirror batcher first). Fall back to a bare
|
|
553
598
|
# transport close when the handler was never built.
|
|
@@ -617,6 +662,7 @@ module ClaudeAgentSDK
|
|
|
617
662
|
# @param transport_args [Hash] Additional keyword arguments passed to transport_class.new(options, **transport_args)
|
|
618
663
|
def initialize(options: nil, transport_class: SubprocessCLITransport, transport_args: {})
|
|
619
664
|
@options = options || ClaudeAgentOptions.new
|
|
665
|
+
@callback_scheduling = @options.callback_scheduling || :thread
|
|
620
666
|
@transport_class = transport_class
|
|
621
667
|
@transport_args = transport_args
|
|
622
668
|
@transport = nil
|
|
@@ -699,6 +745,8 @@ module ClaudeAgentSDK
|
|
|
699
745
|
# notified via on_error.
|
|
700
746
|
@resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
|
|
701
747
|
|
|
748
|
+
ClaudeAgentSDK.check_inline_isolation(@callback_scheduling)
|
|
749
|
+
|
|
702
750
|
# If anything from materialization onward fails, tear down (closes the
|
|
703
751
|
# subprocess and removes the materialized temp config dir) before
|
|
704
752
|
# surfacing the error, so a partial connect never leaks a temp dir
|
|
@@ -751,7 +799,7 @@ module ClaudeAgentSDK
|
|
|
751
799
|
|
|
752
800
|
begin
|
|
753
801
|
if prompt.is_a?(String)
|
|
754
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt)
|
|
802
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt, scheduling: @callback_scheduling)
|
|
755
803
|
message = {
|
|
756
804
|
type: 'user',
|
|
757
805
|
message: { role: 'user', content: prompt },
|
|
@@ -792,8 +840,8 @@ module ClaudeAgentSDK
|
|
|
792
840
|
message = MessageParser.parse(data)
|
|
793
841
|
next unless message
|
|
794
842
|
|
|
795
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
|
|
796
|
-
signal = FiberBoundary.invoke_iteration(block, message)
|
|
843
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message, scheduling: @callback_scheduling)
|
|
844
|
+
signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling)
|
|
797
845
|
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
798
846
|
end
|
|
799
847
|
rescue StandardError => e
|
|
@@ -818,8 +866,8 @@ module ClaudeAgentSDK
|
|
|
818
866
|
message = MessageParser.parse(data)
|
|
819
867
|
next unless message
|
|
820
868
|
|
|
821
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
|
|
822
|
-
signal = FiberBoundary.invoke_iteration(block, message)
|
|
869
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message, scheduling: @callback_scheduling)
|
|
870
|
+
signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling)
|
|
823
871
|
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
824
872
|
break if message.is_a?(ResultMessage)
|
|
825
873
|
end
|
|
@@ -911,7 +959,7 @@ module ClaudeAgentSDK
|
|
|
911
959
|
|
|
912
960
|
# Disconnect from Claude
|
|
913
961
|
def disconnect
|
|
914
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close) if @connected
|
|
962
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close, scheduling: @callback_scheduling) if @connected
|
|
915
963
|
# Tear down whatever exists — robust to a partial/failed connect, where
|
|
916
964
|
# @connected is still false but a transport and/or materialized temp dir
|
|
917
965
|
# were already created. #close on the query handler also closes the
|
|
@@ -997,7 +1045,8 @@ module ClaudeAgentSDK
|
|
|
997
1045
|
sdk_mcp_servers: sdk_mcp_servers,
|
|
998
1046
|
agents: configured_options.agents,
|
|
999
1047
|
exclude_dynamic_sections: exclude_dynamic_sections,
|
|
1000
|
-
skills: configured_options.skills
|
|
1048
|
+
skills: configured_options.skills,
|
|
1049
|
+
callback_scheduling: @callback_scheduling
|
|
1001
1050
|
)
|
|
1002
1051
|
|
|
1003
1052
|
# Mirror transcripts to the session_store, if configured.
|
|
@@ -1028,7 +1077,7 @@ module ClaudeAgentSDK
|
|
|
1028
1077
|
# Observer#on_error contract; notifying a swallowed error would mark
|
|
1029
1078
|
# a still-live OTel trace as failed). Same behavior as query()'s
|
|
1030
1079
|
# streaming path.
|
|
1031
|
-
observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers)
|
|
1080
|
+
observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers, scheduling: @callback_scheduling)
|
|
1032
1081
|
@query_handler.spawn_task { @query_handler.stream_input(observed) }
|
|
1033
1082
|
end
|
|
1034
1083
|
end
|
|
@@ -1045,12 +1094,12 @@ module ClaudeAgentSDK
|
|
|
1045
1094
|
when Hash
|
|
1046
1095
|
msg = msg.merge(session_id: session_id) unless msg.key?(:session_id) || msg.key?('session_id')
|
|
1047
1096
|
if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
|
|
1048
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text)
|
|
1097
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text, scheduling: @callback_scheduling)
|
|
1049
1098
|
end
|
|
1050
1099
|
writeln(JSON.generate(msg))
|
|
1051
1100
|
when String
|
|
1052
1101
|
if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
|
|
1053
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text)
|
|
1102
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text, scheduling: @callback_scheduling)
|
|
1054
1103
|
end
|
|
1055
1104
|
writeln(msg)
|
|
1056
1105
|
else
|
|
@@ -1064,7 +1113,7 @@ module ClaudeAgentSDK
|
|
|
1064
1113
|
# Notify observers of an error surfacing to the consumer. `|| []` keeps a
|
|
1065
1114
|
# mis-scoped call before connect harmless instead of NoMethodError on nil.
|
|
1066
1115
|
def notify_error(error)
|
|
1067
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error)
|
|
1116
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error, scheduling: @callback_scheduling)
|
|
1068
1117
|
end
|
|
1069
1118
|
|
|
1070
1119
|
# 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.25.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
|