Class: ClaudeAgentSDK::Query
- Inherits:
-
Object
- Object
- ClaudeAgentSDK::Query
- Defined in:
- lib/claude_agent_sdk/query.rb
Overview
Handles bidirectional control protocol on top of Transport
This class manages:
- Control request/response routing
- Hook callbacks
- Tool permission callbacks
- Message streaming
- Initialization handshake
Defined Under Namespace
Classes: ThreadWaiter
Constant Summary collapse
- CONTROL_REQUEST_TIMEOUT_ENV_VAR =
'CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS'- DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS =
1200.0
Instance Attribute Summary collapse
-
#is_streaming_mode ⇒ Object
readonly
Returns the value of attribute is_streaming_mode.
-
#sdk_mcp_servers ⇒ Object
readonly
Returns the value of attribute sdk_mcp_servers.
-
#transport ⇒ Object
readonly
Returns the value of attribute transport.
Instance Method Summary collapse
-
#close ⇒ Object
Close the query and transport.
-
#get_context_usage ⇒ Hash
Get a breakdown of current context window usage by category.
-
#get_mcp_status ⇒ Hash
Get current MCP server connection status (only works with streaming mode).
-
#initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil, exclude_dynamic_sections: nil, skills: nil) ⇒ Query
constructor
A new instance of Query.
-
#initialize_protocol ⇒ Hash?
Initialize control protocol if in streaming mode.
-
#interrupt ⇒ Object
Send interrupt control request.
-
#mirror_batches_dropped? ⇒ Boolean
True when the mirror dropped at least one batch (store copy incomplete).
-
#receive_messages(&block) ⇒ Object
Receive SDK messages (not control messages).
-
#reconnect_mcp_server(server_name) ⇒ Object
Reconnect a failed MCP server.
-
#report_mirror_error(key, error) ⇒ Object
Synthesize a
mirror_errorsystem message and put it on the SDK message stream so consumers learn a mirror batch was dropped after exhausting retries. -
#rewind_files(user_message_uuid) ⇒ Object
Rewind files to a previous checkpoint (v0.1.15+) Restores file state to what it was at the given user message Requires enable_file_checkpointing to be true in options.
-
#set_model(model) ⇒ Object
Change the AI model.
-
#set_permission_mode(mode) ⇒ Object
Change permission mode.
-
#set_transcript_mirror_batcher(batcher) ⇒ Object
Install the transcript-mirror batcher fed by
transcript_mirrorframes (Client mode with a session_store). -
#spawn_task(&block) ⇒ Object
Spawn a child task that is stopped by #close (mirrors the Python SDK's Query#spawn_task / _child_tasks).
-
#start ⇒ Object
Start reading messages from transport.
-
#stop_task(task_id) ⇒ Object
Stop a running background task.
-
#stream_input(stream) ⇒ Object
Stream input messages to transport.
-
#toggle_mcp_server(server_name, enabled) ⇒ Object
Enable or disable an MCP server.
-
#wait_for_result_and_end_input ⇒ Object
Wait for the first result before closing stdin when hooks or SDK MCP servers may still need to exchange control messages with the CLI.
- #write(string) ⇒ Object
- #writeln(string) ⇒ Object
Constructor Details
#initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil, exclude_dynamic_sections: nil, skills: nil) ⇒ Query
Returns a new instance of Query.
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/claude_agent_sdk/query.rb', line 48 def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil, exclude_dynamic_sections: nil, skills: nil) @transport = transport @is_streaming_mode = is_streaming_mode @can_use_tool = can_use_tool @hooks = hooks || {} @sdk_mcp_servers = sdk_mcp_servers || {} @agents = agents @exclude_dynamic_sections = exclude_dynamic_sections @skills = skills # Control protocol state @pending_control_responses = {} @pending_control_results = {} @hook_callbacks = {} @hook_callback_timeouts = {} @next_callback_id = 0 @request_counter = 0 @request_counter_mutex = Mutex.new @inflight_control_request_tasks = {} # Message stream @message_queue = Async::Queue.new @first_result_received = false @last_error_result_text = nil @first_result_condition = Async::Condition.new @task = nil @child_tasks = [] @initialized = false @closed = false @initialization_result = nil @transcript_mirror_batcher = nil # Cross-thread close marshaling (see #close). Thread::Queue is the one # primitive that is both fiber-scheduler-aware on the reactor side and # thread-safe on the caller side (push -> scheduler#unblock). @close_requests = ::Thread::Queue.new @close_watcher = nil @owning_scheduler = nil # First-caller-wins guard for close_now (see there). @close_mutex = Mutex.new @close_started = false end |
Instance Attribute Details
#is_streaming_mode ⇒ Object (readonly)
Returns the value of attribute is_streaming_mode.
21 22 23 |
# File 'lib/claude_agent_sdk/query.rb', line 21 def is_streaming_mode @is_streaming_mode end |
#sdk_mcp_servers ⇒ Object (readonly)
Returns the value of attribute sdk_mcp_servers.
21 22 23 |
# File 'lib/claude_agent_sdk/query.rb', line 21 def sdk_mcp_servers @sdk_mcp_servers end |
#transport ⇒ Object (readonly)
Returns the value of attribute transport.
21 22 23 |
# File 'lib/claude_agent_sdk/query.rb', line 21 def transport @transport end |
Instance Method Details
#close ⇒ Object
Close the query and transport.
Callable from any thread. Async::Task#stop needs the reactor's Fiber.scheduler (per-thread), which foreign callers — FiberBoundary workers running a tool handler / hook that calls client.disconnect, or plain user threads — don't have; stopping from one raised NoMethodError and left the read/child tasks running. Such callers hand the close to the reactor-side watcher (spawned in #start) and wait for it to finish, so close semantics are identical regardless of the calling thread.
1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 |
# File 'lib/claude_agent_sdk/query.rb', line 1199 def close if @close_watcher&.alive? && !Fiber.scheduler.equal?(@owning_scheduler) marshal_close_to_reactor else # Same scheduler (reactor-side caller, including the watcher itself), # or no live watcher: when the reactor is gone its task fibers are # dead, so stopping them no longer touches Fiber.scheduler. close_now end end |
#get_context_usage ⇒ Hash
Get a breakdown of current context window usage by category.
1036 1037 1038 |
# File 'lib/claude_agent_sdk/query.rb', line 1036 def get_context_usage send_control_request({ subtype: 'get_context_usage' }) end |
#get_mcp_status ⇒ Hash
Get current MCP server connection status (only works with streaming mode)
1042 1043 1044 |
# File 'lib/claude_agent_sdk/query.rb', line 1042 def get_mcp_status send_control_request({ subtype: 'mcp_status' }) end |
#initialize_protocol ⇒ Hash?
Initialize control protocol if in streaming mode
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/claude_agent_sdk/query.rb', line 94 def initialize_protocol return nil unless @is_streaming_mode # Build hooks configuration for initialization hooks_config = {} if @hooks && !@hooks.empty? @hooks.each do |event, matchers| next if matchers.nil? || matchers.empty? hooks_config[event] = [] matchers.each do |matcher| callback_ids = [] (matcher[:hooks] || []).each do |callback| callback_id = "hook_#{@next_callback_id}" @next_callback_id += 1 @hook_callbacks[callback_id] = callback @hook_callback_timeouts[callback_id] = matcher[:timeout] if matcher[:timeout] callback_ids << callback_id end matcher_config = { matcher: matcher[:matcher], hookCallbackIds: callback_ids } # Wire field is literal "timeout" in SECONDS, per matcher, # omitted when absent (Python _internal/query.py parity — no # camelCase, no ms conversion). Local enforcement via # @hook_callback_timeouts stays as defense-in-depth for CLIs # that ignore the field. matcher_config[:timeout] = matcher[:timeout] if matcher[:timeout] hooks_config[event] << matcher_config end end end # Build agents dict for initialization agents_dict = nil if @agents agents_dict = @agents.transform_values do |agent_def| { description: agent_def.description, prompt: agent_def.prompt, tools: agent_def.tools, disallowedTools: agent_def.disallowed_tools, model: agent_def.model, skills: agent_def.skills, memory: agent_def.memory, mcpServers: agent_def.mcp_servers, initialPrompt: agent_def.initial_prompt, maxTurns: agent_def.max_turns, background: agent_def.background, effort: agent_def.effort, permissionMode: agent_def. }.compact end end # Send initialize request request = { subtype: 'initialize', hooks: hooks_config.empty? ? nil : hooks_config, agents: agents_dict } request[:excludeDynamicSections] = @exclude_dynamic_sections unless @exclude_dynamic_sections.nil? # 'all' and omitted are equivalent at the wire level (no filter), so # only send the field when it's an explicit list (mirrors Python). request[:skills] = @skills if @skills.is_a?(Array) response = send_control_request(request) @initialized = true @initialization_result = response response end |
#interrupt ⇒ Object
Send interrupt control request
1047 1048 1049 |
# File 'lib/claude_agent_sdk/query.rb', line 1047 def interrupt send_control_request({ subtype: 'interrupt' }) end |
#mirror_batches_dropped? ⇒ Boolean
True when the mirror dropped at least one batch (store copy incomplete). Meaningful after #close, which runs the final flush. Consulted by the resume-from-store teardown to decide whether the materialized temp dir holds the only copy of some turns and must be preserved.
240 241 242 |
# File 'lib/claude_agent_sdk/query.rb', line 240 def mirror_batches_dropped? !!@transcript_mirror_batcher&.batches_dropped? end |
#receive_messages(&block) ⇒ Object
Receive SDK messages (not control messages)
1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 |
# File 'lib/claude_agent_sdk/query.rb', line 1174 def (&block) return enum_for(:receive_messages) unless block # NOT Kernel#loop: it rescues StopIteration, so a user block leaking one # (e.g. calling .next on an exhausted Enumerator) would silently end # reception — ResultMessage dropped, the query reported as complete, and # on_error never fired. `while` propagates it like any other error. while true # rubocop:disable Style/InfiniteLoop = @message_queue.dequeue break if [:type] == 'end' raise [:error] if [:type] == 'error' block.call() end end |
#reconnect_mcp_server(server_name) ⇒ Object
Reconnect a failed MCP server
1069 1070 1071 1072 1073 1074 |
# File 'lib/claude_agent_sdk/query.rb', line 1069 def reconnect_mcp_server(server_name) send_control_request({ subtype: 'mcp_reconnect', serverName: server_name }) end |
#report_mirror_error(key, error) ⇒ Object
Synthesize a mirror_error system message and put it on the SDK message
stream so consumers learn a mirror batch was dropped after exhausting
retries. Non-blocking: the message queue is unbounded, so unlike the
Python SDK there is no buffer-full drop path.
248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/claude_agent_sdk/query.rb', line 248 def report_mirror_error(key, error) session_id = key && (key['session_id'] || key[:session_id]) @message_queue.enqueue( type: 'system', subtype: 'mirror_error', error: error, key: key, uuid: SecureRandom.uuid, session_id: session_id || '' ) end |
#rewind_files(user_message_uuid) ⇒ Object
Rewind files to a previous checkpoint (v0.1.15+) Restores file state to what it was at the given user message Requires enable_file_checkpointing to be true in options
1100 1101 1102 1103 1104 1105 |
# File 'lib/claude_agent_sdk/query.rb', line 1100 def rewind_files() send_control_request({ subtype: 'rewind_files', user_message_id: }) end |
#set_model(model) ⇒ Object
Change the AI model
1060 1061 1062 1063 1064 1065 |
# File 'lib/claude_agent_sdk/query.rb', line 1060 def set_model(model) send_control_request({ subtype: 'set_model', model: model }) end |
#set_permission_mode(mode) ⇒ Object
Change permission mode
1052 1053 1054 1055 1056 1057 |
# File 'lib/claude_agent_sdk/query.rb', line 1052 def (mode) send_control_request({ subtype: 'set_permission_mode', mode: mode }) end |
#set_transcript_mirror_batcher(batcher) ⇒ Object
Install the transcript-mirror batcher fed by transcript_mirror frames
(Client mode with a session_store). nil disables mirroring.
232 233 234 |
# File 'lib/claude_agent_sdk/query.rb', line 232 def set_transcript_mirror_batcher(batcher) @transcript_mirror_batcher = batcher end |
#spawn_task(&block) ⇒ Object
Spawn a child task that is stopped by #close (mirrors the Python SDK's Query#spawn_task / _child_tasks). Used for background input streaming so a dying read loop or #close can never strand the stream task and hang the enclosing Async reactor.
NOTE: intentionally a partial mirror — Python prunes completed tasks via add_done_callback(_child_tasks.discard); here entries live until #close. Fine for the current one-shot call sites (max two tasks per Query); do not route per-request work (control handlers, per-turn streams) through this without adding completion-based removal.
221 222 223 224 225 226 227 228 |
# File 'lib/claude_agent_sdk/query.rb', line 221 def spawn_task(&block) parent = Async::Task.current? raise CLIConnectionError, 'Query#spawn_task must be called inside an Async{} block' unless parent task = parent.async(&block) @child_tasks << task task end |
#start ⇒ Object
Start reading messages from transport.
Spawns read_messages as a direct child task of the current Async
task and stores that child in @task. An earlier version wrapped
task.async { read_messages } inside an outer Async do ... end and
assigned the outer task to @task; the outer task completed almost
immediately after spawning, so close's @task.stop never reached
the actual read_messages fiber and the read loop kept running
until the transport raised. Now @task.stop stops the read loop.
Must be called inside an Async{} block (matches query() which wraps
its own internals in Async, and the documented Client#connect
pattern). If invoked outside a reactor, raise a clear error rather
than letting Async::Task.current raise an opaque "No async task
available!" — earlier versions of this method appeared to work
from synchronous callers but actually hung indefinitely because the
outer Async{} root task waited for read_messages to finish, which
never happens for a live Client.
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/claude_agent_sdk/query.rb', line 185 def start return if @task parent = Async::Task.current? raise CLIConnectionError, 'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})' unless parent @owning_scheduler = Fiber.scheduler @task = parent.async { } # Reactor-side agent for #close calls arriving from foreign threads # (FiberBoundary callbacks, plain user threads): Async::Task#stop needs # the owning thread's Fiber.scheduler, so the off-thread caller hands the # whole close over and waits. Transient: must never keep the reactor # alive, and is stopped automatically when the parent task finishes. # One-shot: after serving a close it is done; a reactor-side close wakes # it via @close_requests.close (pop -> nil) so it exits without serving. @close_watcher = parent.async(transient: true) do if (reply = @close_requests.pop) begin close ensure reply << true end end end end |
#stop_task(task_id) ⇒ Object
Stop a running background task
1089 1090 1091 1092 1093 1094 |
# File 'lib/claude_agent_sdk/query.rb', line 1089 def stop_task(task_id) send_control_request({ subtype: 'stop_task', task_id: task_id }) end |
#stream_input(stream) ⇒ Object
Stream input messages to transport. NOTE: iteration runs on the reactor (the deliberate FiberBoundary carve-out — see fiber_boundary.rb): scheduler-aware blocking (Thread::Queue#pop, sleep, socket IO) parks only this task; CPU-bound or scheduler-opaque work in the enumerator must be moved to a producer Thread by the user.
1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 |
# File 'lib/claude_agent_sdk/query.rb', line 1130 def stream_input(stream) = false stream.each do || break if @closed serialized = .is_a?(Hash) ? JSON.generate() : .to_s writeln(serialized) = true end rescue StandardError => e # Log error but don't raise warn "Error streaming input: #{e.}" ensure # Three teardown shapes: # - #close in progress (@closed, Async::Stop unwinding): do nothing — # the transport is about to be closed, and waiting on # @first_result_condition inside a stopping fiber could suspend # teardown. Mirrors Python, where cancellation skips this entirely. # - A turn is in flight (some message reached the CLI): hold stdin # open until its first result so hooks/SDK MCP control replies can # still be written (no timeout — the result or process exit is # guaranteed to signal). # - No complete message ever reached the CLI (empty stream, or the # stream raised before the first write): no result can ever arrive, # so waiting would park query() forever beside an idle CLI. Close # stdin so the CLI sees EOF and exits. Deliberate improvement over # Python, which leaves stdin open and hangs on this path. unless @closed if wait_for_result_and_end_input else @transport.end_input end end end |
#toggle_mcp_server(server_name, enabled) ⇒ Object
Enable or disable an MCP server
1079 1080 1081 1082 1083 1084 1085 |
# File 'lib/claude_agent_sdk/query.rb', line 1079 def toggle_mcp_server(server_name, enabled) send_control_request({ subtype: 'mcp_toggle', serverName: server_name, enabled: enabled }) end |
#wait_for_result_and_end_input ⇒ Object
Wait for the first result before closing stdin when hooks or SDK MCP servers may still need to exchange control messages with the CLI. The control protocol requires stdin to stay open for the entire turn (hook replies, can_use_tool replies and SDK MCP tool results are all written to stdin), so no timeout is applied — closing stdin mid-turn silently broke hooks/MCP on turns longer than the old 60s bound (mirrors Python SDK commit c3d96cb). The condition is guaranteed to be signaled: by the result branch in read_messages, or by its ensure block when the process exits early.
1116 1117 1118 1119 1120 1121 1122 1123 |
# File 'lib/claude_agent_sdk/query.rb', line 1116 def wait_for_result_and_end_input if !@first_result_received && ((@sdk_mcp_servers && !@sdk_mcp_servers.empty?) || (@hooks && !@hooks.empty?)) @first_result_condition.wait end ensure @transport.end_input end |
#write(string) ⇒ Object
1169 1170 1171 |
# File 'lib/claude_agent_sdk/query.rb', line 1169 def write(string) @transport.write(string) end |
#writeln(string) ⇒ Object
1165 1166 1167 |
# File 'lib/claude_agent_sdk/query.rb', line 1165 def writeln(string) write string.end_with?("\n") ? string : "#{string}\n" end |