claude-agent-sdk 0.25.0 → 0.27.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 +10 -0
- data/README.md +1 -1
- data/docs/rails.md +17 -1
- data/docs/sessions.md +44 -1
- data/lib/claude_agent_sdk/fiber_boundary.rb +88 -18
- data/lib/claude_agent_sdk/query.rb +26 -15
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +53 -12
- data/lib/claude_agent_sdk/session_resume.rb +29 -21
- data/lib/claude_agent_sdk/session_store.rb +49 -0
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +31 -3
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +32 -8
- data/lib/claude_agent_sdk/types.rb +24 -4
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +46 -23
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 760ec1fb0cf80f6e56d7b5d3adb20ca2956b209d0961020ae36fa52a13e28144
|
|
4
|
+
data.tar.gz: 6ba365de4438e4081f9bfa47afadc4859d9a001bb6238c0b66f00437cb032626
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 672d6640b3bdefc6c5d9cf599c41b89b6259107f780981da382b18bfa56180788225d5e278ab9a7ef22e79a09a000b48147a1a285b74182b5f4254d8d8ec474e
|
|
7
|
+
data.tar.gz: 0b6eff840de87f744de373bea259fd89a36ca1af33fbc2c678ac84296115b9a5fad841941feabaeb8ebda9c8333d6f4d72a380379c09c410e6358c571af1b26b
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.27.0] - 2026-07-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Fiber-native SessionStore adapters** (#47 phase 3): an adapter whose IO is entirely Fiber-scheduler-aware can declare it by defining an optional `callback_scheduling` method returning `:inline`. Every timeout-bounded store call the SDK makes (mirror-batcher appends; resume-materialization loads **and** the listing methods `list_sessions` / `list_session_summaries` / `list_subkeys`) then runs in place on the reactor fiber under a **cooperative** timeout — interrupted at the next suspension point with `ensure` blocks running — instead of on a throwaway thread with a hard `Thread#join` bound. The outward exception contract is unchanged (`FiberBoundary::JoinTimeout`); undeclared adapters are byte-for-byte untouched, and outside a reactor the hard bound still applies even for declared adapters. Cancellation reaches only the adapter's own fiber (offloaded work may still land afterwards), so timed-out appends are not retried in either mode and may remain permanently half-applied in the store — the drop is surfaced (`MirrorErrorMessage`, `batches_dropped?`) and the local transcript remains the source of truth. Invalid or raising declarations fail fast (`ArgumentError` at construction; conformance contract 17). Apps can opt a third-party fiber-native adapter in via `def store.callback_scheduling = :inline`.
|
|
14
|
+
|
|
15
|
+
## [0.26.0] - 2026-07-31
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **`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.
|
|
19
|
+
|
|
10
20
|
## [0.25.0] - 2026-07-31
|
|
11
21
|
|
|
12
22
|
### Added
|
data/README.md
CHANGED
|
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
|
|
|
68
68
|
gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
|
|
69
69
|
|
|
70
70
|
# Or use a stable version from RubyGems
|
|
71
|
-
gem 'claude-agent-sdk', '~> 0.
|
|
71
|
+
gem 'claude-agent-sdk', '~> 0.27.0'
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
|
data/docs/rails.md
CHANGED
|
@@ -21,6 +21,22 @@ end
|
|
|
21
21
|
|
|
22
22
|
The trade-off: because callbacks run on a plain thread rather than inside an `Async::Task`, fiber-specific primitives aren't available to them — `Async::Task.current` will raise "No async task available". If a callback wants cooperative concurrency it should open its own `Async { }` block. In practice, callbacks typically do some Ruby work, call external services, and return — so this rarely matters. If you wrap your own call site in an outer `Async { }` block, the scheduler is visible to your code again; you've opted in, and whatever fiber-safety rules your app uses apply there.
|
|
23
23
|
|
|
24
|
+
### Rails executor around callbacks: `callback_wrapper`
|
|
25
|
+
|
|
26
|
+
One consequence of the thread hop: an ActiveRecord connection implicitly checked out inside a callback belongs to that throwaway thread and stays stranded until the pool reaper reclaims it. Rails' own answer to "code running on a thread Rails didn't create" is the executor — and `callback_wrapper` lets you install it around every user-callback dispatch:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
ClaudeAgentSDK.configure do |config|
|
|
30
|
+
config.default_options = {
|
|
31
|
+
callback_wrapper: ->(invocation) { Rails.application.executor.wrap { invocation.call } }
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The wrapper is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: `executor.wrap` runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers like `executor.wrap` are safe, including around a `break` from a message block. Beyond the executor, this is a generic hook for APM span propagation, `CurrentAttributes`/logging context, etc.
|
|
37
|
+
|
|
38
|
+
When do you want this vs `callback_scheduling: :inline`? `callback_wrapper` + default `:thread` mode is the right choice for ordinary threaded hosts (Puma, threaded Sidekiq/solid_queue): it fixes connection hygiene without any fiber-isolation precondition. `:inline` is only for hosts that are fiber-isolated end to end (solid_queue fiber workers with `isolation_level = :fiber`); there the wrapper still applies — it simply runs in place on the reactor fiber.
|
|
39
|
+
|
|
24
40
|
## Fiber workers (solid_queue) and `callback_scheduling: :inline`
|
|
25
41
|
|
|
26
42
|
[solid_queue 728](https://github.com/rails/solid_queue/pull/728) added a fiber-based worker mode: workers configured with `fibers: N` run claimed jobs as fibers on one async reactor thread — built for exactly the long-lived, I/O-bound "LLM streaming" jobs this SDK produces. It requires the app to be fiber-isolated end to end:
|
|
@@ -64,7 +80,7 @@ end
|
|
|
64
80
|
|
|
65
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`.
|
|
66
82
|
|
|
67
|
-
Note that `SessionStore` adapter calls (`#append` / `#load`)
|
|
83
|
+
Note that `SessionStore` adapter calls (`#append` / `#load`) stay on threads by default even in `:inline` mode — their timeouts are hard bounds (`Thread#join`) so a wedged store adapter can never stall the reactor. The exception is an adapter that declares itself fiber-native via an optional `callback_scheduling` method returning `:inline` (see "Fiber-native adapters" in [docs/sessions.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/sessions.md)); its calls then run on the reactor under a cooperative timeout.
|
|
68
84
|
|
|
69
85
|
## ActionCable Streaming
|
|
70
86
|
|
data/docs/sessions.md
CHANGED
|
@@ -154,7 +154,8 @@ during resume materialization, default `60_000`).
|
|
|
154
154
|
> Python and TypeScript SDKs.
|
|
155
155
|
>
|
|
156
156
|
> The temp dir is deleted at disconnect — **unless the mirror dropped batches**
|
|
157
|
-
> (
|
|
157
|
+
> (terminal append failures — timeouts immediately, other failures after up to
|
|
158
|
+
> three attempts — surfaced as `MirrorErrorMessage`):
|
|
158
159
|
> the store copy is then incomplete and the temp dir holds the only copy of the
|
|
159
160
|
> dropped turns, so the SDK scrubs the credential copies, keeps the transcripts,
|
|
160
161
|
> and warns with the preserved path so you can import them into the store.
|
|
@@ -175,6 +176,48 @@ Copy-in reference adapters for **S3, Redis, and Postgres** live in
|
|
|
175
176
|
[`examples/session_stores/`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/session_stores/README.md), each with a
|
|
176
177
|
production checklist.
|
|
177
178
|
|
|
179
|
+
#### Fiber-native adapters
|
|
180
|
+
|
|
181
|
+
By default the SDK runs every timeout-bounded adapter call (`#append` from the
|
|
182
|
+
mirror batcher, `#load` and friends during resume materialization) on a
|
|
183
|
+
throwaway thread with a hard `Thread#join` timeout, so a wedged adapter can
|
|
184
|
+
never stall the reactor. An adapter whose IO is **entirely
|
|
185
|
+
Fiber-scheduler-aware** (e.g. built on `async`-native clients) can opt out of
|
|
186
|
+
the thread hop by declaring it:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
class MyAsyncStore
|
|
190
|
+
def callback_scheduling = :inline
|
|
191
|
+
# append/load ...
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Declaring `:inline` means the calls run in place on the reactor fiber under a
|
|
196
|
+
**cooperative** timeout. Three consequences to understand before opting in:
|
|
197
|
+
|
|
198
|
+
- The declaration covers **every method the SDK invokes on the adapter** —
|
|
199
|
+
`append`, `load`, `list_sessions`, `list_session_summaries`,
|
|
200
|
+
`list_subkeys` — not just append/load: resume materialization runs the
|
|
201
|
+
listing calls inline too. **All** blocking inside all of them must yield to
|
|
202
|
+
the scheduler. Scheduler-opaque blocking (CPU-bound work, GVL-holding C
|
|
203
|
+
extensions, native drivers the scheduler can't see) stalls every job on
|
|
204
|
+
that worker **and** the cooperative timeout cannot fire while it blocks.
|
|
205
|
+
- Cancellation semantics change: a timed-out call is interrupted at its next
|
|
206
|
+
suspension point and its `ensure` blocks run, instead of being abandoned on
|
|
207
|
+
a thread. The cancellation reaches only the adapter's **own fiber** — work
|
|
208
|
+
the adapter offloaded (descendant tasks, an already-issued remote write)
|
|
209
|
+
may still land afterwards. Timed-out appends are therefore **not retried**
|
|
210
|
+
(same as thread mode; a retry would race that still-landing work), and the
|
|
211
|
+
interrupted append may remain permanently **half-applied** in the store.
|
|
212
|
+
The drop is surfaced like every dropped batch — `MirrorErrorMessage` on
|
|
213
|
+
the stream, `batches_dropped?` on the batcher — and the local transcript
|
|
214
|
+
remains the source of truth, so nothing is lost from the session itself.
|
|
215
|
+
|
|
216
|
+
Anything other than `:thread`/`:inline` raises `ArgumentError` when the
|
|
217
|
+
session is set up; without a reactor the hard thread-hop bound still applies
|
|
218
|
+
even for declared adapters. To opt in a third-party fiber-native adapter you
|
|
219
|
+
don't own: `def store.callback_scheduling = :inline` (singleton method).
|
|
220
|
+
|
|
178
221
|
### Store-backed helpers
|
|
179
222
|
|
|
180
223
|
The browsing/mutation helpers above have store-backed counterparts that take a
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'async'
|
|
4
|
+
|
|
3
5
|
module ClaudeAgentSDK
|
|
4
6
|
# Internal. Consumers of the SDK should never need this directly.
|
|
5
7
|
#
|
|
@@ -38,11 +40,19 @@ module ClaudeAgentSDK
|
|
|
38
40
|
# it via a scoped, invalidatable fiber-storage entry instead — see
|
|
39
41
|
# SCHEDULING_KEY / SchedulingScope below.
|
|
40
42
|
#
|
|
41
|
-
# A `timeout:`
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
43
|
+
# A `timeout:` forces the thread hop — `Thread#join(timeout)` is a hard
|
|
44
|
+
# bound that can abandon a wedged call, which cooperative `with_timeout`
|
|
45
|
+
# cancellation cannot guarantee — unless the caller passes
|
|
46
|
+
# `scheduling: :inline` inside a reactor, where the bound becomes a
|
|
47
|
+
# cooperative `with_timeout` cancellation instead (issue #47 phase 3:
|
|
48
|
+
# fiber-native SessionStore adapters that declare
|
|
49
|
+
# `callback_scheduling -> :inline`). The store-adapter call sites
|
|
50
|
+
# (TranscriptMirrorBatcher, SessionResume) default to the thread hop so
|
|
51
|
+
# an undeclared (possibly scheduler-opaque) adapter can never stall the
|
|
52
|
+
# reactor; a declared-inline adapter accepts that a scheduler-opaque
|
|
53
|
+
# blocking call would stall it AND escape the cooperative deadline.
|
|
54
|
+
# Outside a reactor the hard bound applies even to inline-declared
|
|
55
|
+
# adapters — the timeout guarantee is never lost.
|
|
46
56
|
#
|
|
47
57
|
# The thread hop severs `break`/`return`/`next` from the surrounding method,
|
|
48
58
|
# so SDK loops yielding user callbacks must keep loop control outside the
|
|
@@ -65,8 +75,9 @@ module ClaudeAgentSDK
|
|
|
65
75
|
class JoinTimeout < StandardError; end
|
|
66
76
|
|
|
67
77
|
# Cancellation injected into INLINE user callbacks by timeout
|
|
68
|
-
# enforcement (hook timeouts under scheduling: :inline
|
|
69
|
-
#
|
|
78
|
+
# enforcement (hook timeouts under scheduling: :inline; store-adapter
|
|
79
|
+
# timeouts for adapters declaring callback_scheduling :inline) — user
|
|
80
|
+
# code should let it propagate. Deliberately
|
|
70
81
|
# NOT a StandardError: the exception is raised inside user code at a
|
|
71
82
|
# suspension point, and a callback's ordinary `rescue StandardError`
|
|
72
83
|
# must not be able to swallow the cancellation and convert an expired
|
|
@@ -97,12 +108,16 @@ module ClaudeAgentSDK
|
|
|
97
108
|
# fall back to the server's own default. Benign race on close vs. a
|
|
98
109
|
# concurrent reader: either value is a defensible mode for a call that
|
|
99
110
|
# straddles the dispatch boundary.
|
|
111
|
+
# Also carries the session's callback wrapper — the two travel (and are
|
|
112
|
+
# invalidated) together, so a descendant that outlives the dispatch can
|
|
113
|
+
# neither run with the session's mode nor with its wrapper.
|
|
100
114
|
# @api private
|
|
101
115
|
class SchedulingScope
|
|
102
|
-
attr_reader :mode
|
|
116
|
+
attr_reader :mode, :wrapper
|
|
103
117
|
|
|
104
|
-
def initialize(mode)
|
|
118
|
+
def initialize(mode, wrapper = nil)
|
|
105
119
|
@mode = mode
|
|
120
|
+
@wrapper = wrapper
|
|
106
121
|
@active = true
|
|
107
122
|
end
|
|
108
123
|
|
|
@@ -129,18 +144,64 @@ module ClaudeAgentSDK
|
|
|
129
144
|
# Run the given block on a plain thread when a Fiber scheduler is active.
|
|
130
145
|
# Returns the block's value. Exceptions propagate to the caller.
|
|
131
146
|
#
|
|
132
|
-
# With +timeout+ (seconds) the thread hop happens
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
147
|
+
# With +timeout+ (seconds) the thread hop happens regardless of a
|
|
148
|
+
# scheduler being active, so the bound is enforced in plain synchronous
|
|
149
|
+
# code too; JoinTimeout is raised when it expires. Exception: with
|
|
150
|
+
# `scheduling: :inline` INSIDE an Async task, the block instead runs in
|
|
151
|
+
# place under a cooperative `with_timeout` — the deadline is delivered
|
|
152
|
+
# as InlineCancellation at the block's next suspension point (its
|
|
153
|
+
# ensure blocks run; not swallowable by `rescue StandardError`) and
|
|
154
|
+
# surfaces as the same JoinTimeout, so callers need no changes. When
|
|
155
|
+
# inline is requested but no Async task is present, the hard
|
|
156
|
+
# thread-hop bound applies — the timeout guarantee is never lost.
|
|
136
157
|
#
|
|
137
158
|
# With `scheduling: :inline` (and no timeout) the block runs in place on
|
|
138
159
|
# the current fiber, scheduler or not. The caller opts in via
|
|
139
160
|
# `ClaudeAgentOptions#callback_scheduling`.
|
|
140
|
-
|
|
141
|
-
|
|
161
|
+
#
|
|
162
|
+
# With +wrapper+ (a callable, from `ClaudeAgentOptions#callback_wrapper`)
|
|
163
|
+
# the executed body becomes `wrapper.call(block)` — composed BEFORE the
|
|
164
|
+
# thread hop, so the wrapper runs on the same execution context as the
|
|
165
|
+
# callback: on the worker thread in :thread mode (the whole point —
|
|
166
|
+
# `Rails.application.executor.wrap` must run on the thread that touches
|
|
167
|
+
# ActiveRecord), in place on the reactor fiber in :inline mode. The
|
|
168
|
+
# wrapper must call its argument and return its value; exceptions from
|
|
169
|
+
# the callback propagate through it unchanged, and exceptions raised by
|
|
170
|
+
# the wrapper itself are treated exactly like callback exceptions. The
|
|
171
|
+
# wrapper must not swallow exceptions and must return
|
|
172
|
+
# the invocation's value — a user's `break` in a message block reaches
|
|
173
|
+
# the wrapper as a clean return (invoke_iteration translates it BEFORE
|
|
174
|
+
# the wrapper sees it), never as an exception a rescue/report wrapper
|
|
175
|
+
# could falsely flag. In inline/no-scheduler mode `break` unwinds natively through the
|
|
176
|
+
# wrapper's stack, so ensure-based wrappers (executor.wrap) are safe.
|
|
177
|
+
#
|
|
178
|
+
# The timeout path deliberately ignores the wrapper: it belongs to the
|
|
179
|
+
# store-adapter carve-out (TranscriptMirrorBatcher, SessionResume),
|
|
180
|
+
# which never carries user callbacks.
|
|
181
|
+
def invoke(timeout: nil, scheduling: :thread, wrapper: nil, &block)
|
|
182
|
+
body = wrapper && timeout.nil? ? -> { wrapper.call(block) } : block
|
|
183
|
+
return body.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler)
|
|
184
|
+
|
|
185
|
+
# Cooperative timeout for inline-declared store adapters: in place on
|
|
186
|
+
# the reactor fiber, cancelled at the next suspension point. The
|
|
187
|
+
# wrapper stays ignored on timeout paths (store adapters are never
|
|
188
|
+
# wrapped), so `body` is the bare block here. The cancellation class
|
|
189
|
+
# is a fresh per-invocation subclass: rescuing the shared base would
|
|
190
|
+
# also catch an OUTER timeout's cancellation delivered while this
|
|
191
|
+
# call is suspended (nested with_timeout — e.g. a store call inside a
|
|
192
|
+
# timed inline hook), mis-attributing the outer deadline to this call
|
|
193
|
+
# and letting execution continue past it. An outer cancellation is a
|
|
194
|
+
# different subclass, so it propagates through untouched.
|
|
195
|
+
if timeout && scheduling == :inline && (task = Async::Task.current?)
|
|
196
|
+
cancellation = Class.new(InlineCancellation)
|
|
197
|
+
begin
|
|
198
|
+
return task.with_timeout(timeout, cancellation, &block)
|
|
199
|
+
rescue cancellation
|
|
200
|
+
raise JoinTimeout, "timed out after #{timeout}s"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
142
203
|
|
|
143
|
-
thread = Thread.new(&
|
|
204
|
+
thread = Thread.new(&body)
|
|
144
205
|
thread.report_on_exception = false
|
|
145
206
|
return thread.value if timeout.nil?
|
|
146
207
|
raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout)
|
|
@@ -155,8 +216,15 @@ module ClaudeAgentSDK
|
|
|
155
216
|
# Returns Break when the user broke, nil when the block completed.
|
|
156
217
|
# Without a scheduler (or with `scheduling: :inline`) the block runs in
|
|
157
218
|
# place and `break` unwinds natively, never reaching the translation.
|
|
158
|
-
|
|
159
|
-
|
|
219
|
+
# The LocalJumpError -> Break translation lives INSIDE the invocation
|
|
220
|
+
# handed to the +wrapper+: a user's `break` is normal loop control, not
|
|
221
|
+
# an error, so a conforming rescue/report/re-raise wrapper must observe
|
|
222
|
+
# a clean return (the Break sentinel as the invocation's value), never
|
|
223
|
+
# a LocalJumpError it could falsely report or swallow. In inline /
|
|
224
|
+
# no-scheduler mode `break` unwinds natively through the wrapper's
|
|
225
|
+
# stack instead (ensure-based wrappers still run their cleanup).
|
|
226
|
+
def invoke_iteration(block, *args, scheduling: :thread, wrapper: nil)
|
|
227
|
+
invocation = lambda do
|
|
160
228
|
block.call(*args)
|
|
161
229
|
nil
|
|
162
230
|
rescue LocalJumpError => e
|
|
@@ -164,6 +232,8 @@ module ClaudeAgentSDK
|
|
|
164
232
|
|
|
165
233
|
Break.new(e.exit_value)
|
|
166
234
|
end
|
|
235
|
+
body = wrapper ? -> { wrapper.call(invocation) } : invocation
|
|
236
|
+
invoke(scheduling: scheduling) { body.call }
|
|
167
237
|
end
|
|
168
238
|
end
|
|
169
239
|
end
|
|
@@ -63,13 +63,14 @@ module ClaudeAgentSDK
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
|
|
66
|
-
exclude_dynamic_sections: nil, skills: nil, callback_scheduling: :thread)
|
|
66
|
+
exclude_dynamic_sections: nil, skills: nil, callback_scheduling: :thread, callback_wrapper: nil)
|
|
67
67
|
@transport = transport
|
|
68
68
|
@is_streaming_mode = is_streaming_mode
|
|
69
69
|
@can_use_tool = can_use_tool
|
|
70
70
|
@hooks = hooks || {}
|
|
71
71
|
@sdk_mcp_servers = sdk_mcp_servers || {}
|
|
72
72
|
@callback_scheduling = callback_scheduling || :thread
|
|
73
|
+
@callback_wrapper = callback_wrapper
|
|
73
74
|
@agents = agents
|
|
74
75
|
@exclude_dynamic_sections = exclude_dynamic_sections
|
|
75
76
|
@skills = skills
|
|
@@ -269,8 +270,9 @@ module ClaudeAgentSDK
|
|
|
269
270
|
end
|
|
270
271
|
|
|
271
272
|
# Synthesize a `mirror_error` system message and put it on the SDK message
|
|
272
|
-
# stream so consumers learn a mirror batch was dropped
|
|
273
|
-
#
|
|
273
|
+
# stream so consumers learn a mirror batch was dropped (timeouts
|
|
274
|
+
# immediately, other failures after up to three attempts).
|
|
275
|
+
# Non-blocking: the message queue is unbounded, so unlike the
|
|
274
276
|
# Python SDK there is no buffer-full drop path.
|
|
275
277
|
def report_mirror_error(key, error)
|
|
276
278
|
session_id = key && (key['session_id'] || key[:session_id])
|
|
@@ -570,7 +572,7 @@ module ClaudeAgentSDK
|
|
|
570
572
|
# with callback_scheduling: :inline it runs in place on this control-
|
|
571
573
|
# request task, where control_cancel_request (task.stop) can actually
|
|
572
574
|
# cancel it at suspension points.
|
|
573
|
-
response = FiberBoundary.invoke(scheduling: @callback_scheduling) do
|
|
575
|
+
response = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
|
|
574
576
|
@can_use_tool.call(request_data[:tool_name], request_data[:input], context)
|
|
575
577
|
end
|
|
576
578
|
|
|
@@ -615,7 +617,7 @@ module ClaudeAgentSDK
|
|
|
615
617
|
# suspension point and its ensure blocks run (Python parity — anyio
|
|
616
618
|
# cancels the coroutine). A CPU-stuck inline hook cannot be timed out.
|
|
617
619
|
unless @hook_callback_timeouts[callback_id]
|
|
618
|
-
hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling) do
|
|
620
|
+
hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
|
|
619
621
|
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
620
622
|
end
|
|
621
623
|
end
|
|
@@ -629,19 +631,27 @@ module ClaudeAgentSDK
|
|
|
629
631
|
# convert the expired hook into a success (or keep running past
|
|
630
632
|
# the deadline). Inject a non-StandardError cancellation
|
|
631
633
|
# instead, translated back once control leaves user code so the
|
|
632
|
-
# outward contract (Async::TimeoutError) is unchanged.
|
|
634
|
+
# outward contract (Async::TimeoutError) is unchanged. The
|
|
635
|
+
# wrapper composes INSIDE with_timeout, and the cancellation
|
|
636
|
+
# passes through it un-swallowed (InlineCancellation is not a
|
|
637
|
+
# StandardError, so a wrapper's ordinary rescue can't eat it).
|
|
638
|
+
# Fresh per-invocation subclass: rescuing the shared base would
|
|
639
|
+
# also catch an OUTER timeout's cancellation (e.g. an inline
|
|
640
|
+
# store call nested inside this hook already uses its own), so
|
|
641
|
+
# each timeout scope must only consume its own deadline.
|
|
633
642
|
begin
|
|
634
|
-
|
|
635
|
-
|
|
643
|
+
cancellation = Class.new(FiberBoundary::InlineCancellation)
|
|
644
|
+
Async::Task.current.with_timeout(timeout, cancellation) do
|
|
645
|
+
FiberBoundary.invoke(scheduling: :inline, wrapper: @callback_wrapper) do
|
|
636
646
|
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
637
647
|
end
|
|
638
648
|
end
|
|
639
|
-
rescue
|
|
649
|
+
rescue cancellation
|
|
640
650
|
raise Async::TimeoutError, 'execution expired'
|
|
641
651
|
end
|
|
642
652
|
else
|
|
643
653
|
Async::Task.current.with_timeout(timeout) do
|
|
644
|
-
FiberBoundary.invoke do
|
|
654
|
+
FiberBoundary.invoke(wrapper: @callback_wrapper) do
|
|
645
655
|
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
646
656
|
end
|
|
647
657
|
end
|
|
@@ -994,10 +1004,11 @@ module ClaudeAgentSDK
|
|
|
994
1004
|
end
|
|
995
1005
|
|
|
996
1006
|
def handle_sdk_mcp_request(server_name, message)
|
|
997
|
-
# Carry this session's scheduling mode
|
|
998
|
-
# (possibly session-shared) SdkMcpServer via fiber
|
|
999
|
-
# the dispatching fiber, read back by the server's
|
|
1000
|
-
# time (see
|
|
1007
|
+
# Carry this session's scheduling mode and callback wrapper across the
|
|
1008
|
+
# dispatch into the (possibly session-shared) SdkMcpServer via fiber
|
|
1009
|
+
# storage — set on the dispatching fiber, read back by the server's
|
|
1010
|
+
# handlers at invoke time (see
|
|
1011
|
+
# SdkMcpServer#effective_callback_scheduling / _wrapper). Fiber
|
|
1001
1012
|
# storage is per-fiber, so concurrent sessions cannot see each
|
|
1002
1013
|
# other's value even across suspension points. The value is a
|
|
1003
1014
|
# closable SchedulingScope, closed + restored in the ensure below:
|
|
@@ -1008,7 +1019,7 @@ module ClaudeAgentSDK
|
|
|
1008
1019
|
# into later direct server calls, and nothing stays stamped on
|
|
1009
1020
|
# long-lived fibers.
|
|
1010
1021
|
previous_scheduling = Fiber[FiberBoundary::SCHEDULING_KEY]
|
|
1011
|
-
dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling)
|
|
1022
|
+
dispatch_scope = FiberBoundary::SchedulingScope.new(@callback_scheduling, @callback_wrapper)
|
|
1012
1023
|
Fiber[FiberBoundary::SCHEDULING_KEY] = dispatch_scope
|
|
1013
1024
|
|
|
1014
1025
|
# Convert server_name to symbol if needed for hash lookup
|
|
@@ -90,17 +90,47 @@ module ClaudeAgentSDK
|
|
|
90
90
|
# so modes cannot cross-contaminate or persist past a session.
|
|
91
91
|
attr_accessor :callback_scheduling
|
|
92
92
|
|
|
93
|
+
# Default callback wrapper for DIRECT invocations of this server
|
|
94
|
+
# (call_tool / read_resource / get_prompt outside a session). When a
|
|
95
|
+
# session dispatches to this server, the session's own wrapper arrives
|
|
96
|
+
# via fiber storage instead (see #effective_callback_wrapper) — same
|
|
97
|
+
# never-mutate-the-shared-server rule as callback_scheduling.
|
|
98
|
+
attr_accessor :callback_wrapper
|
|
99
|
+
|
|
93
100
|
# Internal — public only so the dynamic tool classes can reach it. The
|
|
94
|
-
# scheduling mode for the current invocation
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
101
|
+
# (scheduling mode, wrapper) pair for the current invocation, resolved
|
|
102
|
+
# from ONE liveness decision: the dispatching session's pair when a
|
|
103
|
+
# live SchedulingScope is in fiber storage (set by Query around the
|
|
104
|
+
# dispatch), else this server's own defaults. The pair MUST be resolved
|
|
105
|
+
# together — deciding `active?` once per accessor lets the scope close
|
|
106
|
+
# between the two reads (the dispatch's ensure runs concurrently with a
|
|
107
|
+
# descendant reader) and yields a torn mix of session mode with server
|
|
108
|
+
# wrapper. A scope inherited from an already-finished dispatch is
|
|
109
|
+
# closed and deliberately ignored — a child task spawned inside a
|
|
110
|
+
# handler must not carry the session's pair into later direct calls.
|
|
100
111
|
# @api private
|
|
101
|
-
|
|
112
|
+
# @return [Array(Symbol, #call)] `[scheduling, wrapper]`
|
|
113
|
+
def effective_callback_dispatch
|
|
102
114
|
scope = Fiber[FiberBoundary::SCHEDULING_KEY]
|
|
103
|
-
scope&.active?
|
|
115
|
+
if scope&.active?
|
|
116
|
+
[scope.mode, scope.wrapper]
|
|
117
|
+
else
|
|
118
|
+
[@callback_scheduling, @callback_wrapper]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Internal. Prefer #effective_callback_dispatch when both values are
|
|
123
|
+
# needed — separate calls re-decide scope liveness and can tear.
|
|
124
|
+
# @api private
|
|
125
|
+
def effective_callback_scheduling
|
|
126
|
+
effective_callback_dispatch[0]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Internal. Prefer #effective_callback_dispatch when both values are
|
|
130
|
+
# needed — separate calls re-decide scope liveness and can tear.
|
|
131
|
+
# @api private
|
|
132
|
+
def effective_callback_wrapper
|
|
133
|
+
effective_callback_dispatch[1]
|
|
104
134
|
end
|
|
105
135
|
|
|
106
136
|
def initialize(name:, version: '1.0.0', tools: [], resources: [], prompts: [])
|
|
@@ -110,6 +140,7 @@ module ClaudeAgentSDK
|
|
|
110
140
|
@resources = resources
|
|
111
141
|
@prompts = prompts
|
|
112
142
|
@callback_scheduling = :thread
|
|
143
|
+
@callback_wrapper = nil
|
|
113
144
|
|
|
114
145
|
# Create dynamic Tool classes from tool definitions
|
|
115
146
|
tool_classes = create_tool_classes(tools)
|
|
@@ -197,7 +228,10 @@ module ClaudeAgentSDK
|
|
|
197
228
|
# Call the tool's handler on a plain thread (default) so the async
|
|
198
229
|
# gem's Fiber scheduler is not visible to user code (which may hit
|
|
199
230
|
# AR/PG); in :inline mode it runs in place on the reactor fiber.
|
|
200
|
-
|
|
231
|
+
scheduling, wrapper = effective_callback_dispatch
|
|
232
|
+
result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
233
|
+
tool.handler.call(arguments)
|
|
234
|
+
end
|
|
201
235
|
|
|
202
236
|
# Guard before flexible_fetch: it raises on non-Hash inputs.
|
|
203
237
|
content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "content", "content") : nil
|
|
@@ -232,7 +266,10 @@ module ClaudeAgentSDK
|
|
|
232
266
|
# Hop off the Fiber scheduler before invoking user code — same reason
|
|
233
267
|
# as `call_tool` above: reader blocks may touch Thread.current-keyed
|
|
234
268
|
# libraries (ActiveRecord, pg, ...) and must run on a plain thread.
|
|
235
|
-
|
|
269
|
+
scheduling, wrapper = effective_callback_dispatch
|
|
270
|
+
content = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
271
|
+
resource.reader.call
|
|
272
|
+
end
|
|
236
273
|
|
|
237
274
|
# Ensure content has the expected format (symbol or string keys; guard
|
|
238
275
|
# before flexible_fetch — it raises on non-Hash inputs)
|
|
@@ -264,7 +301,10 @@ module ClaudeAgentSDK
|
|
|
264
301
|
|
|
265
302
|
# Hop off the Fiber scheduler before invoking user code — same reason
|
|
266
303
|
# as `call_tool` above.
|
|
267
|
-
|
|
304
|
+
scheduling, wrapper = effective_callback_dispatch
|
|
305
|
+
result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
306
|
+
prompt.generator.call(arguments)
|
|
307
|
+
end
|
|
268
308
|
|
|
269
309
|
# Ensure result has the expected format (symbol or string keys)
|
|
270
310
|
messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "messages", "messages") : nil
|
|
@@ -365,7 +405,8 @@ module ClaudeAgentSDK
|
|
|
365
405
|
# Filter out server_context and pass remaining args to handler.
|
|
366
406
|
# Hop to a plain thread (default) so user handlers don't see
|
|
367
407
|
# the Fiber scheduler; :inline runs in place on the reactor.
|
|
368
|
-
|
|
408
|
+
scheduling, wrapper = @sdk_server.effective_callback_dispatch
|
|
409
|
+
result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
369
410
|
@tool_def.handler.call(args)
|
|
370
411
|
end
|
|
371
412
|
|
|
@@ -108,6 +108,10 @@ module ClaudeAgentSDK
|
|
|
108
108
|
return nil if options.resume.nil? && !options.continue_conversation
|
|
109
109
|
|
|
110
110
|
timeout_s = options.load_timeout_ms / 1000.0
|
|
111
|
+
# Probed ONCE at materialization entry (the resume path's construction
|
|
112
|
+
# point) so an invalid callback_scheduling declaration fails fast here,
|
|
113
|
+
# before any store IO or temp-dir work.
|
|
114
|
+
scheduling = SessionStores.store_callback_scheduling(store)
|
|
111
115
|
project_key = Sessions.project_key_for_directory(options.cwd)
|
|
112
116
|
|
|
113
117
|
resolved =
|
|
@@ -116,9 +120,9 @@ module ClaudeAgentSDK
|
|
|
116
120
|
# prevent traversal and match every other resume path.
|
|
117
121
|
return nil unless options.resume.match?(Sessions::UUID_RE)
|
|
118
122
|
|
|
119
|
-
load_candidate(store, project_key, options.resume, timeout_s)
|
|
123
|
+
load_candidate(store, project_key, options.resume, timeout_s, scheduling)
|
|
120
124
|
else
|
|
121
|
-
resolve_continue_candidate(store, project_key, timeout_s)
|
|
125
|
+
resolve_continue_candidate(store, project_key, timeout_s, scheduling)
|
|
122
126
|
end
|
|
123
127
|
return nil if resolved.nil?
|
|
124
128
|
|
|
@@ -133,7 +137,7 @@ module ClaudeAgentSDK
|
|
|
133
137
|
# so it can authenticate. Missing files are fine (API-key auth, etc.).
|
|
134
138
|
copy_auth_files(tmp_base, options.env)
|
|
135
139
|
|
|
136
|
-
materialize_subkeys(store, project_dir, project_key, session_id, timeout_s) if SessionStore.implements?(store, :list_subkeys)
|
|
140
|
+
materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling) if SessionStore.implements?(store, :list_subkeys)
|
|
137
141
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
138
142
|
# Any failure after mkdtemp leaves tmp_base (which may already hold a
|
|
139
143
|
# .credentials.json copy) on disk with no path for the caller to clean
|
|
@@ -149,8 +153,8 @@ module ClaudeAgentSDK
|
|
|
149
153
|
# -- Helpers --
|
|
150
154
|
|
|
151
155
|
# Load entries for session_id; return [session_id, entries] or nil if empty.
|
|
152
|
-
def load_candidate(store, project_key, session_id, timeout_s)
|
|
153
|
-
entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id}") do
|
|
156
|
+
def load_candidate(store, project_key, session_id, timeout_s, scheduling)
|
|
157
|
+
entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id}", scheduling) do
|
|
154
158
|
store.load('project_key' => project_key, 'session_id' => session_id)
|
|
155
159
|
end
|
|
156
160
|
return nil if entries.nil? || entries.empty?
|
|
@@ -162,13 +166,13 @@ module ClaudeAgentSDK
|
|
|
162
166
|
# transcripts are mirrored as ordinary top-level keys and often have the
|
|
163
167
|
# highest mtime, so walk newest->oldest and skip them so --continue resumes
|
|
164
168
|
# the user's conversation, not a subagent's.
|
|
165
|
-
def resolve_continue_candidate(store, project_key, timeout_s)
|
|
166
|
-
sessions = with_timeout(timeout_s, 'SessionStore#list_sessions') do
|
|
169
|
+
def resolve_continue_candidate(store, project_key, timeout_s, scheduling)
|
|
170
|
+
sessions = with_timeout(timeout_s, 'SessionStore#list_sessions', scheduling) do
|
|
167
171
|
store.list_sessions(project_key)
|
|
168
172
|
end
|
|
169
173
|
return nil if sessions.nil? || sessions.empty?
|
|
170
174
|
|
|
171
|
-
sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s)
|
|
175
|
+
sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling)
|
|
172
176
|
|
|
173
177
|
sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
|
|
174
178
|
sid = cand['session_id']
|
|
@@ -178,7 +182,7 @@ module ClaudeAgentSDK
|
|
|
178
182
|
# --continue O(sum of transcript sizes) instead of O(candidates).
|
|
179
183
|
next if sidechain_flags&.fetch(sid, false)
|
|
180
184
|
|
|
181
|
-
loaded = load_candidate(store, project_key, sid, timeout_s)
|
|
185
|
+
loaded = load_candidate(store, project_key, sid, timeout_s, scheduling)
|
|
182
186
|
next if loaded.nil?
|
|
183
187
|
|
|
184
188
|
first = loaded[1][0]
|
|
@@ -194,10 +198,10 @@ module ClaudeAgentSDK
|
|
|
194
198
|
# fails (callers then fall back to checking each full load). The per-load
|
|
195
199
|
# isSidechain check above stays even on the summary path: a missing or
|
|
196
200
|
# stale sidecar row costs one extra load, never a wrong resume.
|
|
197
|
-
def sidechain_flags_from_summaries(store, project_key, timeout_s)
|
|
201
|
+
def sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling)
|
|
198
202
|
return nil unless SessionStore.implements?(store, :list_session_summaries)
|
|
199
203
|
|
|
200
|
-
rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries') do
|
|
204
|
+
rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries', scheduling) do
|
|
201
205
|
store.list_session_summaries(project_key)
|
|
202
206
|
end
|
|
203
207
|
Array(rows).each_with_object({}) do |row, acc|
|
|
@@ -227,13 +231,17 @@ module ClaudeAgentSDK
|
|
|
227
231
|
|
|
228
232
|
# Run a store call (user code) on a plain thread bounded by timeout_s,
|
|
229
233
|
# re-raising failures/timeouts as RuntimeError with context. The thread hop
|
|
230
|
-
# (FiberBoundary with a timeout
|
|
231
|
-
# out of the user's store code AND enforces load_timeout_ms
|
|
232
|
-
# — including when materialization runs outside an Async
|
|
233
|
-
# direct call would let a hung adapter block connect
|
|
234
|
-
# worker is left running (not killed) since it may
|
|
235
|
-
|
|
236
|
-
|
|
234
|
+
# (the default for FiberBoundary with a timeout) both keeps the async
|
|
235
|
+
# scheduler out of the user's store code AND enforces load_timeout_ms
|
|
236
|
+
# unconditionally — including when materialization runs outside an Async
|
|
237
|
+
# reactor, where a direct call would let a hung adapter block connect
|
|
238
|
+
# forever. A timed-out worker is left running (not killed) since it may
|
|
239
|
+
# still complete. An adapter declaring `callback_scheduling -> :inline`
|
|
240
|
+
# (probed once at materialization entry) instead runs in place under a
|
|
241
|
+
# cooperative timeout when a reactor is present — outside one, the hard
|
|
242
|
+
# thread-hop bound still applies.
|
|
243
|
+
def with_timeout(timeout_s, what, scheduling = :thread, &block)
|
|
244
|
+
FiberBoundary.invoke(timeout: timeout_s, scheduling: scheduling, &block)
|
|
237
245
|
rescue FiberBoundary::JoinTimeout
|
|
238
246
|
raise "#{what} timed out after #{(timeout_s * 1000).to_i}ms during resume materialization"
|
|
239
247
|
rescue RuntimeError
|
|
@@ -363,9 +371,9 @@ module ClaudeAgentSDK
|
|
|
363
371
|
end
|
|
364
372
|
|
|
365
373
|
# Load and write all subagent transcripts/metadata under session_id.
|
|
366
|
-
def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s)
|
|
374
|
+
def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling)
|
|
367
375
|
session_dir = File.join(project_dir, session_id)
|
|
368
|
-
subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}") do
|
|
376
|
+
subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling) do
|
|
369
377
|
store.list_subkeys('project_key' => project_key, 'session_id' => session_id)
|
|
370
378
|
end
|
|
371
379
|
|
|
@@ -377,7 +385,7 @@ module ClaudeAgentSDK
|
|
|
377
385
|
next
|
|
378
386
|
end
|
|
379
387
|
|
|
380
|
-
sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}") do
|
|
388
|
+
sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}", scheduling) do
|
|
381
389
|
store.load('project_key' => project_key, 'session_id' => session_id, 'subpath' => subpath)
|
|
382
390
|
end
|
|
383
391
|
next if sub_entries.nil? || sub_entries.empty?
|
|
@@ -32,6 +32,32 @@ module ClaudeAgentSDK
|
|
|
32
32
|
# - entries: raw JSONL transcript objects (opaque pass-through blobs)
|
|
33
33
|
# - list_sessions result: [{ 'session_id' => String, 'mtime' => Integer }]
|
|
34
34
|
# - summary entries: { 'session_id', 'mtime', 'data' } (see SessionSummary)
|
|
35
|
+
#
|
|
36
|
+
# FIBER-NATIVE ADAPTERS (issue #47 phase 3): an adapter whose IO is
|
|
37
|
+
# entirely Fiber-scheduler-aware may additionally define an optional
|
|
38
|
+
# `callback_scheduling` method returning `:inline` (`:thread` = default
|
|
39
|
+
# behavior). The SDK then runs the adapter's timeout-bounded calls in place
|
|
40
|
+
# on the reactor fiber under a cooperative timeout instead of on a
|
|
41
|
+
# throwaway thread with a hard `Thread#join` bound. The declaration covers
|
|
42
|
+
# EVERY method the SDK invokes on the adapter — append, load,
|
|
43
|
+
# list_sessions, list_session_summaries, list_subkeys — not just
|
|
44
|
+
# append/load: resume materialization inlines the listing calls too. Only
|
|
45
|
+
# the adapter author can make this call — declare :inline ONLY if every
|
|
46
|
+
# blocking operation in every method yields to the scheduler;
|
|
47
|
+
# scheduler-opaque blocking stalls every job on the worker AND escapes the
|
|
48
|
+
# cooperative deadline. A timed-out inline call is interrupted at its next
|
|
49
|
+
# suspension point (ensure blocks run) rather than abandoned. The
|
|
50
|
+
# cancellation reaches only the adapter's own fiber — work the adapter
|
|
51
|
+
# offloaded (descendant tasks, an already-issued remote write) may still
|
|
52
|
+
# land afterwards — so timed-out appends are NOT retried (same as thread
|
|
53
|
+
# mode) and a cancelled append may remain permanently half-applied in the
|
|
54
|
+
# store. The drop is surfaced (MirrorErrorMessage, batches_dropped?) and
|
|
55
|
+
# the local transcript remains the source of truth; the
|
|
56
|
+
# dedupe-by-entry-uuid recommendation above stays advisory. The method is deliberately NOT defined here: the SDK probes
|
|
57
|
+
# `respond_to?(:callback_scheduling)` (see
|
|
58
|
+
# SessionStores.store_callback_scheduling), so pure duck-typed adapters
|
|
59
|
+
# stay minimal, and an app can opt a third-party fiber-native adapter in
|
|
60
|
+
# via a singleton method (`def store.callback_scheduling = :inline`).
|
|
35
61
|
class SessionStore
|
|
36
62
|
# True if +store+ overrides +method+ rather than inheriting the base
|
|
37
63
|
# implementation that raises NotImplementedError. Works for both subclasses
|
|
@@ -249,8 +275,31 @@ module ClaudeAgentSDK
|
|
|
249
275
|
|
|
250
276
|
# Internal SessionStore support functions (path mapping, option validation).
|
|
251
277
|
module SessionStores
|
|
278
|
+
STORE_CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
|
|
279
|
+
|
|
252
280
|
module_function
|
|
253
281
|
|
|
282
|
+
# Where an adapter's timeout-bounded #append/#load calls run: :thread
|
|
283
|
+
# (default — hard-bounded thread hop) unless the adapter declares
|
|
284
|
+
# fiber-nativeness via an optional `callback_scheduling` method returning
|
|
285
|
+
# :inline (String form coerced, matching ClaudeAgentOptions). Probed
|
|
286
|
+
# respond_to?-style like the rest of the subsystem. Called once at
|
|
287
|
+
# construction time (batcher initialize, resume-materialization entry)
|
|
288
|
+
# so an invalid declaration fails fast there, not mid-session.
|
|
289
|
+
def store_callback_scheduling(store)
|
|
290
|
+
return :thread unless store.respond_to?(:callback_scheduling)
|
|
291
|
+
|
|
292
|
+
value = store.callback_scheduling
|
|
293
|
+
mode = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
294
|
+
unless STORE_CALLBACK_SCHEDULING_MODES.include?(mode)
|
|
295
|
+
raise ArgumentError,
|
|
296
|
+
'session_store#callback_scheduling must return one of ' \
|
|
297
|
+
"#{STORE_CALLBACK_SCHEDULING_MODES.map(&:inspect).join(', ')} (got #{value.inspect})"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
mode
|
|
301
|
+
end
|
|
302
|
+
|
|
254
303
|
# Derive a SessionKey from an absolute transcript file path.
|
|
255
304
|
#
|
|
256
305
|
# Main: <projects_dir>/<project_key>/<session_id>.jsonl
|
|
@@ -13,9 +13,11 @@ module ClaudeAgentSDK
|
|
|
13
13
|
|
|
14
14
|
module_function
|
|
15
15
|
|
|
16
|
-
# Assert the
|
|
16
|
+
# Assert the 17 SessionStore behavioral contracts against an adapter.
|
|
17
17
|
#
|
|
18
18
|
# Contracts 1-14 mirror the Python SDK's run_session_store_conformance.
|
|
19
|
+
# Contract 17 (Ruby extension) validates the optional
|
|
20
|
+
# `callback_scheduling` fiber-nativeness declaration (:thread/:inline).
|
|
19
21
|
# Contract 16 (Ruby extension) locks one-row-per-session `list_sessions`
|
|
20
22
|
# under multiple appends — the naive one-row-per-append implementation
|
|
21
23
|
# passed every other contract and then showed N duplicate sessions in
|
|
@@ -62,6 +64,7 @@ module ClaudeAgentSDK
|
|
|
62
64
|
has_delete = optional?(probe, 'delete', skip_optional)
|
|
63
65
|
has_list_subkeys = optional?(probe, 'list_subkeys', skip_optional)
|
|
64
66
|
|
|
67
|
+
check_callback_scheduling_declaration(fresh)
|
|
65
68
|
check_append_and_load(fresh, has_list_sessions)
|
|
66
69
|
check_list_sessions(fresh) if has_list_sessions
|
|
67
70
|
check_list_session_summaries(fresh, has_list_sessions, has_delete) if has_list_summaries
|
|
@@ -71,6 +74,30 @@ module ClaudeAgentSDK
|
|
|
71
74
|
nil
|
|
72
75
|
end
|
|
73
76
|
|
|
77
|
+
# -- Optional: callback_scheduling declaration ---------------------------
|
|
78
|
+
|
|
79
|
+
# 17. an adapter declaring the optional callback_scheduling method (issue
|
|
80
|
+
# #47 phase 3: fiber-native adapters) must return :thread or :inline —
|
|
81
|
+
# the SDK probes it at construction and raises ArgumentError otherwise,
|
|
82
|
+
# so a bad declaration would fail every session using the store. Runs
|
|
83
|
+
# first because the SDK probes before any store IO; skipped entirely for
|
|
84
|
+
# non-declaring adapters (the probe defaults to :thread).
|
|
85
|
+
def check_callback_scheduling_declaration(fresh)
|
|
86
|
+
store = fresh.call
|
|
87
|
+
return unless store.respond_to?(:callback_scheduling)
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
SessionStores.store_callback_scheduling(store)
|
|
91
|
+
rescue ArgumentError => e
|
|
92
|
+
assert(false, "callback_scheduling declaration must be :thread or :inline (#{e.message})")
|
|
93
|
+
rescue StandardError, NotImplementedError => e
|
|
94
|
+
# A raising declaration is as fatal as a bad value — the SDK probes
|
|
95
|
+
# it at construction. Report through the harness's documented
|
|
96
|
+
# ConformanceError instead of leaking the raw exception.
|
|
97
|
+
assert(false, "callback_scheduling declaration raised #{e.class}: #{e.message}")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
74
101
|
# -- Required: append + load -------------------------------------------
|
|
75
102
|
|
|
76
103
|
def check_append_and_load(fresh, has_list_sessions) # rubocop:disable Metrics/MethodLength
|
|
@@ -347,8 +374,9 @@ module ClaudeAgentSDK
|
|
|
347
374
|
"SessionStore conformance failed: #{message}\n expected: #{expected.inspect}\n actual: #{actual.inspect}"
|
|
348
375
|
end
|
|
349
376
|
|
|
350
|
-
private_class_method :
|
|
351
|
-
:
|
|
377
|
+
private_class_method :check_callback_scheduling_declaration, :check_append_and_load, :check_list_sessions,
|
|
378
|
+
:check_list_session_summaries, :check_delete, :check_list_subkeys,
|
|
379
|
+
:check_uuid_dedupe_contract, :key, :entry,
|
|
352
380
|
:epoch_ms?, :optional?, :summaries_by_id, :assert, :assert_eq
|
|
353
381
|
end
|
|
354
382
|
end
|
|
@@ -28,7 +28,14 @@ module ClaudeAgentSDK
|
|
|
28
28
|
# The semaphore serializes appends, but a #send that exceeds send_timeout is
|
|
29
29
|
# abandoned (its worker thread keeps running) and the next drain proceeds, so
|
|
30
30
|
# two #append calls for the SAME key can briefly overlap. SessionStore#append
|
|
31
|
-
# must be thread-safe per key (see that method's contract).
|
|
31
|
+
# must be thread-safe per key (see that method's contract). For adapters
|
|
32
|
+
# declaring `callback_scheduling -> :inline` the timed-out call is instead
|
|
33
|
+
# cancelled cooperatively (interrupted at its next suspension point, ensure
|
|
34
|
+
# runs) — but only on the adapter's own fiber; work the adapter offloaded
|
|
35
|
+
# may still land, so timeouts are not retried in either mode and the
|
|
36
|
+
# cancelled append may remain permanently HALF-applied in the store. The
|
|
37
|
+
# drop is surfaced (MirrorErrorMessage, batches_dropped?); the local
|
|
38
|
+
# transcript remains the source of truth.
|
|
32
39
|
class TranscriptMirrorBatcher
|
|
33
40
|
# Eager-flush thresholds (exposed for tests).
|
|
34
41
|
MAX_PENDING_ENTRIES = 500
|
|
@@ -50,6 +57,9 @@ module ClaudeAgentSDK
|
|
|
50
57
|
@projects_dir = projects_dir
|
|
51
58
|
@on_error = on_error
|
|
52
59
|
@send_timeout = send_timeout
|
|
60
|
+
# Probed ONCE here so an invalid callback_scheduling declaration fails
|
|
61
|
+
# at construction, not mid-session inside a flush.
|
|
62
|
+
@store_scheduling = SessionStores.store_callback_scheduling(store)
|
|
53
63
|
@max_pending_entries = max_pending_entries
|
|
54
64
|
@max_pending_bytes = max_pending_bytes
|
|
55
65
|
@pending = []
|
|
@@ -203,9 +213,18 @@ module ClaudeAgentSDK
|
|
|
203
213
|
succeeded = true
|
|
204
214
|
break
|
|
205
215
|
when :timeout
|
|
206
|
-
# Don't retry on timeout
|
|
207
|
-
#
|
|
208
|
-
#
|
|
216
|
+
# Don't retry on timeout — in EITHER mode. Thread mode: the
|
|
217
|
+
# abandoned in-flight call may still land, so a retry would launch
|
|
218
|
+
# a concurrent duplicate. Inline mode: cooperative cancellation
|
|
219
|
+
# interrupts only the adapter's own fiber — work the adapter
|
|
220
|
+
# itself offloaded (descendant tasks, an already-issued remote
|
|
221
|
+
# write) may still land after the cancellation, so a retry races
|
|
222
|
+
# it exactly like the thread case (adversarially demonstrated in
|
|
223
|
+
# review: a dedupe-conforming adapter still persisted duplicates).
|
|
224
|
+
# Uniform no-retry also bounds worst-case lock hold at
|
|
225
|
+
# ~send_timeout. The dropped batch is surfaced (MirrorErrorMessage,
|
|
226
|
+
# batches_dropped?) and the local transcript remains the source of
|
|
227
|
+
# truth.
|
|
209
228
|
last_err = err
|
|
210
229
|
break
|
|
211
230
|
else # :error — retryable
|
|
@@ -222,11 +241,16 @@ module ClaudeAgentSDK
|
|
|
222
241
|
|
|
223
242
|
# Run SessionStore#append (user code) on a plain thread via FiberBoundary,
|
|
224
243
|
# bounded by send_timeout (enforced with or without an active reactor).
|
|
225
|
-
# Returns [:ok, nil] / [:timeout, err] / [:error, err]. On timeout
|
|
226
|
-
# worker thread is left running (cancellation is
|
|
227
|
-
# call may still land) and not retried.
|
|
244
|
+
# Returns [:ok, nil] / [:timeout, err] / [:error, err]. On timeout in
|
|
245
|
+
# thread mode the worker thread is left running (cancellation is
|
|
246
|
+
# best-effort; the in-flight call may still land) and not retried. An
|
|
247
|
+
# adapter declaring `callback_scheduling -> :inline` instead runs in
|
|
248
|
+
# place on the reactor under a cooperative timeout (interrupted at its
|
|
249
|
+
# next suspension point, ensure blocks run) — same JoinTimeout contract
|
|
250
|
+
# and same no-retry semantics (see append_with_retry: offloaded work
|
|
251
|
+
# may outlive the cancellation).
|
|
228
252
|
def invoke_append(key, entries)
|
|
229
|
-
FiberBoundary.invoke(timeout: @send_timeout) { @store.append(key, entries) }
|
|
253
|
+
FiberBoundary.invoke(timeout: @send_timeout, scheduling: @store_scheduling) { @store.append(key, entries) }
|
|
230
254
|
[:ok, nil]
|
|
231
255
|
rescue FiberBoundary::JoinTimeout
|
|
232
256
|
[:timeout, "append timed out after #{@send_timeout}s"]
|
|
@@ -290,9 +290,11 @@ module ClaudeAgentSDK
|
|
|
290
290
|
attr_accessor :uuid, :session_id, :content
|
|
291
291
|
end
|
|
292
292
|
|
|
293
|
-
# Emitted when a session_store mirror batch
|
|
294
|
-
# dropped
|
|
295
|
-
#
|
|
293
|
+
# Emitted when a session_store mirror batch fails terminally and is
|
|
294
|
+
# dropped — timeouts immediately (never retried), other failures after up
|
|
295
|
+
# to three attempts. The local-disk transcript is still durable; this is
|
|
296
|
+
# the consumer's only signal that the external store missed a batch
|
|
297
|
+
# (at-most-once delivery).
|
|
296
298
|
class MirrorErrorMessage < SystemMessage
|
|
297
299
|
attr_accessor :uuid, :session_id, :error, :key
|
|
298
300
|
end
|
|
@@ -1576,7 +1578,7 @@ module ClaudeAgentSDK
|
|
|
1576
1578
|
attr_reader :bare, :fork_session, :enable_file_checkpointing,
|
|
1577
1579
|
:include_partial_messages, :continue_conversation,
|
|
1578
1580
|
:include_hook_events, :strict_mcp_config,
|
|
1579
|
-
:callback_scheduling
|
|
1581
|
+
:callback_scheduling, :callback_wrapper
|
|
1580
1582
|
|
|
1581
1583
|
def initialize(attributes = {})
|
|
1582
1584
|
self.fork_session = false
|
|
@@ -1704,6 +1706,24 @@ module ClaudeAgentSDK
|
|
|
1704
1706
|
@callback_scheduling = mode
|
|
1705
1707
|
end
|
|
1706
1708
|
|
|
1709
|
+
# Middleware wrapped around EVERY user-callback dispatch (message
|
|
1710
|
+
# blocks, observers, hooks, permission callbacks, SDK MCP handlers).
|
|
1711
|
+
# A callable receiving a zero-arg invocation; it MUST call it and
|
|
1712
|
+
# return its value:
|
|
1713
|
+
#
|
|
1714
|
+
# callback_wrapper: ->(invocation) { Rails.application.executor.wrap { invocation.call } }
|
|
1715
|
+
#
|
|
1716
|
+
# The wrapper runs on the same execution context as the callback —
|
|
1717
|
+
# inside the worker thread in :thread mode (so executor.wrap checks AR
|
|
1718
|
+
# connections back in when the callback ends), in place on the reactor
|
|
1719
|
+
# fiber in :inline mode. Exceptions propagate through it unchanged; it
|
|
1720
|
+
# must not swallow them. Default nil (no wrapping).
|
|
1721
|
+
def callback_wrapper=(value)
|
|
1722
|
+
raise ArgumentError, "callback_wrapper must be a callable or nil (got #{value.inspect})" unless value.nil? || value.respond_to?(:call)
|
|
1723
|
+
|
|
1724
|
+
@callback_wrapper = value
|
|
1725
|
+
end
|
|
1726
|
+
|
|
1707
1727
|
private
|
|
1708
1728
|
|
|
1709
1729
|
# Strict key validation: unlike other Type subclasses (which silently drop
|
data/lib/claude_agent_sdk.rb
CHANGED
|
@@ -84,9 +84,9 @@ module ClaudeAgentSDK
|
|
|
84
84
|
# Each observer is invoked through FiberBoundary so that user code runs
|
|
85
85
|
# on a plain thread (no Fiber scheduler) even when called from inside
|
|
86
86
|
# the SDK's Async reactor — or in place when scheduling is :inline.
|
|
87
|
-
def self.notify_observers(observers, method, *args, scheduling: :thread)
|
|
87
|
+
def self.notify_observers(observers, method, *args, scheduling: :thread, wrapper: nil)
|
|
88
88
|
observers.each do |obs|
|
|
89
|
-
FiberBoundary.invoke(scheduling: scheduling) { obs.send(method, *args) }
|
|
89
|
+
FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) { obs.send(method, *args) }
|
|
90
90
|
rescue StandardError, ScriptError
|
|
91
91
|
# ScriptError too: NotImplementedError < ScriptError (not
|
|
92
92
|
# StandardError), and a stubbed observer must never mask the original
|
|
@@ -187,13 +187,13 @@ module ClaudeAgentSDK
|
|
|
187
187
|
# Wrap a streaming-input enumerable so observers get on_user_prompt for
|
|
188
188
|
# each user message before it is written to stdin. Identity when no
|
|
189
189
|
# observers are configured.
|
|
190
|
-
def self.observing_prompt_stream(prompt, observers, scheduling: :thread)
|
|
190
|
+
def self.observing_prompt_stream(prompt, observers, scheduling: :thread, wrapper: nil)
|
|
191
191
|
return prompt if observers.empty?
|
|
192
192
|
|
|
193
193
|
Enumerator.new do |yielder|
|
|
194
194
|
prompt.each do |message|
|
|
195
195
|
text = extract_user_prompt_text(message)
|
|
196
|
-
notify_observers(observers, :on_user_prompt, text, scheduling: scheduling) if text
|
|
196
|
+
notify_observers(observers, :on_user_prompt, text, scheduling: scheduling, wrapper: wrapper) if text
|
|
197
197
|
yielder << message
|
|
198
198
|
end
|
|
199
199
|
end
|
|
@@ -461,8 +461,10 @@ module ClaudeAgentSDK
|
|
|
461
461
|
# Resolve callable observers into fresh instances (thread-safe for global defaults)
|
|
462
462
|
resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers)
|
|
463
463
|
|
|
464
|
-
# Where user callbacks run (see ClaudeAgentOptions#callback_scheduling)
|
|
464
|
+
# Where user callbacks run (see ClaudeAgentOptions#callback_scheduling)
|
|
465
|
+
# and the middleware wrapped around them (#callback_wrapper).
|
|
465
466
|
callback_scheduling = configured_options.callback_scheduling || :thread
|
|
467
|
+
callback_wrapper = configured_options.callback_wrapper
|
|
466
468
|
ClaudeAgentSDK.check_inline_isolation(callback_scheduling)
|
|
467
469
|
|
|
468
470
|
raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' if transport && !transport.respond_to?(:connect)
|
|
@@ -525,7 +527,8 @@ module ClaudeAgentSDK
|
|
|
525
527
|
sdk_mcp_servers: sdk_mcp_servers,
|
|
526
528
|
exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
|
|
527
529
|
skills: configured_options.skills,
|
|
528
|
-
callback_scheduling: callback_scheduling
|
|
530
|
+
callback_scheduling: callback_scheduling,
|
|
531
|
+
callback_wrapper: callback_wrapper
|
|
529
532
|
)
|
|
530
533
|
|
|
531
534
|
# Mirror transcripts to the session_store, if configured. Installed
|
|
@@ -549,7 +552,8 @@ module ClaudeAgentSDK
|
|
|
549
552
|
|
|
550
553
|
# Send prompt(s) as user messages, then close stdin
|
|
551
554
|
if prompt.is_a?(String)
|
|
552
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt,
|
|
555
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt,
|
|
556
|
+
scheduling: callback_scheduling, wrapper: callback_wrapper)
|
|
553
557
|
message = {
|
|
554
558
|
type: 'user',
|
|
555
559
|
message: { role: 'user', content: prompt },
|
|
@@ -567,7 +571,8 @@ module ClaudeAgentSDK
|
|
|
567
571
|
# here kept the root reactor alive forever when the read loop died
|
|
568
572
|
# while the user enumerator was still blocked (matches Python's
|
|
569
573
|
# query.spawn_task(query.stream_input(prompt))).
|
|
570
|
-
observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers,
|
|
574
|
+
observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers,
|
|
575
|
+
scheduling: callback_scheduling, wrapper: callback_wrapper)
|
|
571
576
|
query_handler.spawn_task { query_handler.stream_input(observed_prompt) }
|
|
572
577
|
end
|
|
573
578
|
|
|
@@ -579,8 +584,10 @@ module ClaudeAgentSDK
|
|
|
579
584
|
message = MessageParser.parse(data)
|
|
580
585
|
next unless message
|
|
581
586
|
|
|
582
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message,
|
|
583
|
-
|
|
587
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message,
|
|
588
|
+
scheduling: callback_scheduling, wrapper: callback_wrapper)
|
|
589
|
+
signal = FiberBoundary.invoke_iteration(block, message, scheduling: callback_scheduling,
|
|
590
|
+
wrapper: callback_wrapper)
|
|
584
591
|
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
585
592
|
end
|
|
586
593
|
rescue StandardError => e
|
|
@@ -589,10 +596,12 @@ module ClaudeAgentSDK
|
|
|
589
596
|
# parse errors, and user-block errors. StandardError only: Async::Stop
|
|
590
597
|
# is cancellation, not an error. Bare raise preserves the backtrace;
|
|
591
598
|
# the ensure below still fires on_close after on_error.
|
|
592
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e,
|
|
599
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e,
|
|
600
|
+
scheduling: callback_scheduling, wrapper: callback_wrapper)
|
|
593
601
|
raise
|
|
594
602
|
ensure
|
|
595
|
-
ClaudeAgentSDK.notify_observers(resolved_observers, :on_close,
|
|
603
|
+
ClaudeAgentSDK.notify_observers(resolved_observers, :on_close,
|
|
604
|
+
scheduling: callback_scheduling, wrapper: callback_wrapper)
|
|
596
605
|
# query_handler.close stops the background read task and closes the
|
|
597
606
|
# transport (flushing the mirror batcher first). Fall back to a bare
|
|
598
607
|
# transport close when the handler was never built.
|
|
@@ -663,6 +672,7 @@ module ClaudeAgentSDK
|
|
|
663
672
|
def initialize(options: nil, transport_class: SubprocessCLITransport, transport_args: {})
|
|
664
673
|
@options = options || ClaudeAgentOptions.new
|
|
665
674
|
@callback_scheduling = @options.callback_scheduling || :thread
|
|
675
|
+
@callback_wrapper = @options.callback_wrapper
|
|
666
676
|
@transport_class = transport_class
|
|
667
677
|
@transport_args = transport_args
|
|
668
678
|
@transport = nil
|
|
@@ -799,7 +809,8 @@ module ClaudeAgentSDK
|
|
|
799
809
|
|
|
800
810
|
begin
|
|
801
811
|
if prompt.is_a?(String)
|
|
802
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt,
|
|
812
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt,
|
|
813
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
803
814
|
message = {
|
|
804
815
|
type: 'user',
|
|
805
816
|
message: { role: 'user', content: prompt },
|
|
@@ -840,8 +851,10 @@ module ClaudeAgentSDK
|
|
|
840
851
|
message = MessageParser.parse(data)
|
|
841
852
|
next unless message
|
|
842
853
|
|
|
843
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
|
|
844
|
-
|
|
854
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
|
|
855
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
856
|
+
signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling,
|
|
857
|
+
wrapper: @callback_wrapper)
|
|
845
858
|
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
846
859
|
end
|
|
847
860
|
rescue StandardError => e
|
|
@@ -866,8 +879,10 @@ module ClaudeAgentSDK
|
|
|
866
879
|
message = MessageParser.parse(data)
|
|
867
880
|
next unless message
|
|
868
881
|
|
|
869
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
|
|
870
|
-
|
|
882
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
|
|
883
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
884
|
+
signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling,
|
|
885
|
+
wrapper: @callback_wrapper)
|
|
871
886
|
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
872
887
|
break if message.is_a?(ResultMessage)
|
|
873
888
|
end
|
|
@@ -959,7 +974,10 @@ module ClaudeAgentSDK
|
|
|
959
974
|
|
|
960
975
|
# Disconnect from Claude
|
|
961
976
|
def disconnect
|
|
962
|
-
|
|
977
|
+
if @connected
|
|
978
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close,
|
|
979
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
980
|
+
end
|
|
963
981
|
# Tear down whatever exists — robust to a partial/failed connect, where
|
|
964
982
|
# @connected is still false but a transport and/or materialized temp dir
|
|
965
983
|
# were already created. #close on the query handler also closes the
|
|
@@ -1046,7 +1064,8 @@ module ClaudeAgentSDK
|
|
|
1046
1064
|
agents: configured_options.agents,
|
|
1047
1065
|
exclude_dynamic_sections: exclude_dynamic_sections,
|
|
1048
1066
|
skills: configured_options.skills,
|
|
1049
|
-
callback_scheduling: @callback_scheduling
|
|
1067
|
+
callback_scheduling: @callback_scheduling,
|
|
1068
|
+
callback_wrapper: @callback_wrapper
|
|
1050
1069
|
)
|
|
1051
1070
|
|
|
1052
1071
|
# Mirror transcripts to the session_store, if configured.
|
|
@@ -1077,7 +1096,8 @@ module ClaudeAgentSDK
|
|
|
1077
1096
|
# Observer#on_error contract; notifying a swallowed error would mark
|
|
1078
1097
|
# a still-live OTel trace as failed). Same behavior as query()'s
|
|
1079
1098
|
# streaming path.
|
|
1080
|
-
observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers,
|
|
1099
|
+
observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers,
|
|
1100
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
1081
1101
|
@query_handler.spawn_task { @query_handler.stream_input(observed) }
|
|
1082
1102
|
end
|
|
1083
1103
|
end
|
|
@@ -1094,12 +1114,14 @@ module ClaudeAgentSDK
|
|
|
1094
1114
|
when Hash
|
|
1095
1115
|
msg = msg.merge(session_id: session_id) unless msg.key?(:session_id) || msg.key?('session_id')
|
|
1096
1116
|
if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
|
|
1097
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
|
|
1117
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
|
|
1118
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
1098
1119
|
end
|
|
1099
1120
|
writeln(JSON.generate(msg))
|
|
1100
1121
|
when String
|
|
1101
1122
|
if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
|
|
1102
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
|
|
1123
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
|
|
1124
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
1103
1125
|
end
|
|
1104
1126
|
writeln(msg)
|
|
1105
1127
|
else
|
|
@@ -1113,7 +1135,8 @@ module ClaudeAgentSDK
|
|
|
1113
1135
|
# Notify observers of an error surfacing to the consumer. `|| []` keeps a
|
|
1114
1136
|
# mis-scoped call before connect harmless instead of NoMethodError on nil.
|
|
1115
1137
|
def notify_error(error)
|
|
1116
|
-
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error,
|
|
1138
|
+
ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error,
|
|
1139
|
+
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
1117
1140
|
end
|
|
1118
1141
|
|
|
1119
1142
|
# Build and install the transcript-mirror batcher on the query handler when
|