claude-agent-sdk 1.1.0 → 1.2.1
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/.yardopts +10 -0
- data/CHANGELOG.md +99 -0
- data/README.md +43 -31
- data/docs/cli-installer.md +26 -4
- data/docs/client.md +29 -11
- data/docs/configuration.md +164 -1
- data/docs/errors.md +32 -2
- data/docs/hooks-and-permissions.md +30 -10
- data/docs/mcp-servers.md +30 -9
- data/docs/observability.md +61 -10
- data/docs/options.md +232 -0
- data/docs/rails.md +263 -18
- data/docs/sessions.md +40 -12
- data/docs/subagents.md +1 -1
- data/docs/types.md +100 -11
- data/lib/claude_agent_sdk/cli_installer.rb +140 -19
- data/lib/claude_agent_sdk/command_builder.rb +83 -108
- data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
- data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
- data/lib/claude_agent_sdk/option_forms.rb +379 -0
- data/lib/claude_agent_sdk/query/run_lifecycle.rb +373 -0
- data/lib/claude_agent_sdk/query.rb +266 -364
- data/lib/claude_agent_sdk/railtie.rb +27 -2
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
- data/lib/claude_agent_sdk/session_assembly.rb +311 -0
- data/lib/claude_agent_sdk/session_mutations.rb +112 -92
- data/lib/claude_agent_sdk/session_resume.rb +360 -43
- data/lib/claude_agent_sdk/session_store.rb +31 -2
- data/lib/claude_agent_sdk/sessions.rb +720 -138
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +228 -29
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
- data/lib/claude_agent_sdk/transport.rb +28 -12
- data/lib/claude_agent_sdk/types/attributes.rb +9 -0
- data/lib/claude_agent_sdk/types/base.rb +85 -15
- data/lib/claude_agent_sdk/types/hooks.rb +73 -0
- data/lib/claude_agent_sdk/types/mcp.rb +37 -1
- data/lib/claude_agent_sdk/types/option_values.rb +186 -4
- data/lib/claude_agent_sdk/types/options.rb +35 -5
- data/lib/claude_agent_sdk/types/permissions.rb +18 -9
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +223 -418
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
- data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
- data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
- data/sig/claude_agent_sdk/types/options.rbs +11 -7
- data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
- metadata +9 -4
|
@@ -57,11 +57,18 @@ module ClaudeAgentSDK
|
|
|
57
57
|
# the interlock keeps a reload from unloading code under it until the
|
|
58
58
|
# whole SDK call returns.
|
|
59
59
|
# 3. Otherwise (production: no reloading, concurrency allowed) — run the
|
|
60
|
-
# callback inside
|
|
60
|
+
# callback inside the executor (see .run_in_executor).
|
|
61
61
|
#
|
|
62
62
|
# The configuration is read on every call, so the same wrapper is correct
|
|
63
63
|
# in every environment.
|
|
64
64
|
#
|
|
65
|
+
# The wrapper reports nothing to `Rails.error`, in any branch. An exception
|
|
66
|
+
# that escapes a callback reaches the code that called the SDK, whose own
|
|
67
|
+
# layer (the request middleware, ActiveJob) reports it with its context;
|
|
68
|
+
# the failures the SDK handles itself — a hook or tool error answered to
|
|
69
|
+
# the CLI, a swallowed observer error, a cancellation — are not errors of
|
|
70
|
+
# the application.
|
|
71
|
+
#
|
|
65
72
|
# @return [Proc] a callable suitable for `callback_wrapper:`
|
|
66
73
|
# @example config/initializers/claude_agent_sdk.rb
|
|
67
74
|
# ClaudeAgentSDK.configure do |config|
|
|
@@ -71,7 +78,7 @@ module ClaudeAgentSDK
|
|
|
71
78
|
lambda do |invocation|
|
|
72
79
|
app = ::Rails.application
|
|
73
80
|
next invocation.call if app.nil? || app.executor.active?
|
|
74
|
-
next app.executor
|
|
81
|
+
next run_in_executor(app.executor, invocation) unless executor_locks?(app.config)
|
|
75
82
|
|
|
76
83
|
begin
|
|
77
84
|
invocation.call
|
|
@@ -81,6 +88,24 @@ module ClaudeAgentSDK
|
|
|
81
88
|
end
|
|
82
89
|
end
|
|
83
90
|
|
|
91
|
+
# `executor.wrap { invocation.call }` minus its error report. `wrap`
|
|
92
|
+
# rescues what passes through it (every Exception on Rails 8.1, 8.0.2+
|
|
93
|
+
# and 7.2.3+; StandardError before) and reports it to `Rails.error` as
|
|
94
|
+
# unhandled, from a callback thread that has none of the caller's
|
|
95
|
+
# context — and Rails then skips the same exception as already reported
|
|
96
|
+
# when it reaches the request or job. It reported the SDK's own
|
|
97
|
+
# cancellations and the callback failures the SDK answers or swallows,
|
|
98
|
+
# too. `run!` / `complete!` run the same hooks.
|
|
99
|
+
def self.run_in_executor(executor, invocation)
|
|
100
|
+
execution = executor.run!
|
|
101
|
+
begin
|
|
102
|
+
invocation.call
|
|
103
|
+
ensure
|
|
104
|
+
execution.complete!
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
private_class_method :run_in_executor
|
|
108
|
+
|
|
84
109
|
# Whether railties registered a process-wide lock hook on the executor
|
|
85
110
|
# (Rails::Application::Finisher, initializer
|
|
86
111
|
# :configure_executor_for_concurrency).
|
|
@@ -36,6 +36,9 @@ module ClaudeAgentSDK
|
|
|
36
36
|
# mangled into nonsense parameter lists ("additionalProperties" as a
|
|
37
37
|
# required string param). A $ref-only schema without type: 'object' remains
|
|
38
38
|
# indistinguishable from a params hash — declare the type alongside $ref.
|
|
39
|
+
# For the same reason { type: :object } is this prebuilt accept-any-object
|
|
40
|
+
# schema even though :object is also a shorthand type: a simple schema with
|
|
41
|
+
# an object parameter literally named type has to spell it { type: Hash }.
|
|
39
42
|
# @api private
|
|
40
43
|
def self.prebuilt_json_schema?(schema)
|
|
41
44
|
return false unless schema.is_a?(Hash)
|
|
@@ -67,13 +70,16 @@ module ClaudeAgentSDK
|
|
|
67
70
|
# @api private
|
|
68
71
|
def self.ruby_type_to_json_schema(type)
|
|
69
72
|
# Class#=== matches instances, not the class object used in { id: Integer }.
|
|
70
|
-
type = { String => :string, Integer => :integer, Float => :float,
|
|
71
|
-
|
|
73
|
+
type = { String => :string, Integer => :integer, Float => :float, TrueClass => :boolean,
|
|
74
|
+
FalseClass => :boolean, Array => :array, Hash => :object }.fetch(type, type)
|
|
72
75
|
case type
|
|
73
76
|
when :string, String then { type: 'string' }
|
|
74
77
|
when :integer, Integer then { type: 'integer' }
|
|
75
78
|
when :float, Float, :number then { type: 'number' }
|
|
76
79
|
when :boolean, TrueClass, FalseClass then { type: 'boolean' }
|
|
80
|
+
# The class or the Symbol only. An Array or Hash VALUE ([String], a nested
|
|
81
|
+
# schema fragment) is not a shorthand and falls through like before.
|
|
82
|
+
when :array, :object then { type: type.to_s }
|
|
77
83
|
else { type: 'string' } # rubocop:disable Lint/DuplicateBranch -- default fallback; the :string arm stays explicit
|
|
78
84
|
end
|
|
79
85
|
end
|
|
@@ -301,13 +307,25 @@ module ClaudeAgentSDK
|
|
|
301
307
|
ClaudeAgentSDK.normalize_tool_result(tool.handler.call(arguments))
|
|
302
308
|
end
|
|
303
309
|
|
|
304
|
-
# Guard before flexible_fetch: it raises on non-Hash inputs.
|
|
305
|
-
|
|
306
|
-
|
|
310
|
+
# Guard before flexible_fetch: it raises on non-Hash inputs. This first
|
|
311
|
+
# diagnostic is about the KEY: a present false or nil is a wrong value
|
|
312
|
+
# and gets the Array diagnostic below, like any other non-Array.
|
|
313
|
+
unless result.is_a?(Hash) && (result.key?(:content) || result.key?('content'))
|
|
314
|
+
return error_tool_result("Tool '#{name}' must return a hash with :content key")
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content')
|
|
318
|
+
# A String or a single block here is not a result an MCP client accepts.
|
|
319
|
+
unless content.is_a?(Array)
|
|
320
|
+
return error_tool_result("Tool '#{name}' must return :content as an Array of content blocks " \
|
|
321
|
+
"(got #{content.class})")
|
|
322
|
+
end
|
|
307
323
|
|
|
308
324
|
result
|
|
309
|
-
rescue
|
|
310
|
-
# Bare e.message like Python's str(e) — no prefix.
|
|
325
|
+
rescue *FiberBoundary::CALLBACK_FAILURES => e
|
|
326
|
+
# Bare e.message like Python's str(e) — no prefix. The list, not just
|
|
327
|
+
# StandardError: a handler's NotImplementedError, LoadError or
|
|
328
|
+
# SystemStackError is a tool failure the model should read as well.
|
|
311
329
|
error_tool_result(e.message)
|
|
312
330
|
end
|
|
313
331
|
|
|
@@ -412,7 +430,7 @@ module ClaudeAgentSDK
|
|
|
412
430
|
end
|
|
413
431
|
|
|
414
432
|
# Create dynamic Tool classes from tool definitions
|
|
415
|
-
def create_tool_classes(tools) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- builds each dynamic MCP::Tool subclass inline
|
|
433
|
+
def create_tool_classes(tools) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- builds each dynamic MCP::Tool subclass inline
|
|
416
434
|
# Captured so the dynamic class can resolve the effective scheduling
|
|
417
435
|
# mode at call time — same pattern as prompt classes.
|
|
418
436
|
sdk_server = self
|
|
@@ -444,16 +462,22 @@ module ClaudeAgentSDK
|
|
|
444
462
|
end
|
|
445
463
|
|
|
446
464
|
def input_schema_value
|
|
447
|
-
# Full-schema construction: the gem JSON-round-trips
|
|
448
|
-
# validates against the
|
|
449
|
-
#
|
|
450
|
-
#
|
|
451
|
-
#
|
|
452
|
-
#
|
|
453
|
-
#
|
|
454
|
-
#
|
|
455
|
-
#
|
|
456
|
-
#
|
|
465
|
+
# Full-schema construction: the gem JSON-round-trips the schema
|
|
466
|
+
# and validates it against the JSON Schema 2020-12 metaschema
|
|
467
|
+
# (every mcp version this gem supports; older ones used draft4).
|
|
468
|
+
# additionalProperties/enum/description survive, and so do a
|
|
469
|
+
# numeric exclusiveMinimum and same-document $ref/$defs. Empty
|
|
470
|
+
# required arrays are stripped — a holdover from draft4, whose
|
|
471
|
+
# metaschema mandated a non-empty required.
|
|
472
|
+
#
|
|
473
|
+
# A schema the gem refuses (ArgumentError: a draft4-style
|
|
474
|
+
# boolean exclusiveMinimum, an unknown type, a $ref that leaves
|
|
475
|
+
# the document, an invalid pattern, ...) falls back to a
|
|
476
|
+
# permissive schema with a warning, once per tool: the tool
|
|
477
|
+
# keeps working with argument validation disabled instead of
|
|
478
|
+
# being permanently uncallable while tools/list advertises it
|
|
479
|
+
# as healthy. (The warning's "not draft4-compatible" wording
|
|
480
|
+
# dates from the draft4 days.)
|
|
457
481
|
@input_schema_value ||= begin
|
|
458
482
|
schema = ClaudeAgentSDK.normalize_tool_schema(@tool_def.input_schema)
|
|
459
483
|
schema = schema.except(:required) if schema[:required].is_a?(Array) && schema[:required].empty?
|
|
@@ -493,11 +517,21 @@ module ClaudeAgentSDK
|
|
|
493
517
|
|
|
494
518
|
# Guard BEFORE flexible_fetch: on a non-Hash it raises
|
|
495
519
|
# TypeError/NoMethodError, surfacing garbage instead of the
|
|
496
|
-
# friendly message.
|
|
497
|
-
|
|
520
|
+
# friendly message. This first diagnostic is about the KEY: a
|
|
521
|
+
# present false or nil is a wrong value and gets the Array
|
|
522
|
+
# diagnostic below, like any other non-Array.
|
|
523
|
+
unless result.is_a?(Hash) && (result.key?(:content) || result.key?('content'))
|
|
524
|
+
raise "Tool '#{@tool_def.name}' must return a hash with :content key"
|
|
525
|
+
end
|
|
498
526
|
|
|
499
527
|
content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content')
|
|
500
|
-
|
|
528
|
+
# A String or a single block Hash would go out as it is, and the
|
|
529
|
+
# CLI rejects that frame against its schema: the model is told
|
|
530
|
+
# the SERVER returned a malformed result and never sees the text.
|
|
531
|
+
unless content.is_a?(Array)
|
|
532
|
+
raise "Tool '#{@tool_def.name}' must return :content as an Array of content blocks " \
|
|
533
|
+
"(got #{content.class})"
|
|
534
|
+
end
|
|
501
535
|
|
|
502
536
|
is_error = ClaudeAgentSDK.flexible_fetch(result, 'isError', 'is_error')
|
|
503
537
|
structured_content = ClaudeAgentSDK.flexible_fetch(result, 'structuredContent', 'structured_content')
|
|
@@ -507,12 +541,15 @@ module ClaudeAgentSDK
|
|
|
507
541
|
error: !!is_error,
|
|
508
542
|
structured_content: structured_content
|
|
509
543
|
)
|
|
510
|
-
rescue
|
|
544
|
+
rescue *FiberBoundary::CALLBACK_FAILURES => e
|
|
511
545
|
# Report handler failures in-band HERE rather than letting them
|
|
512
546
|
# reach the gem: mcp >= 1.2 deliberately drops e.message from
|
|
513
547
|
# its "Internal error calling tool X" wrapper (CWE-209), which
|
|
514
548
|
# would hide the text the model needs to self-correct. Bare
|
|
515
549
|
# e.message like Python's str(e) and #call_tool — no prefix.
|
|
550
|
+
# The list, not just StandardError: the gem rescues only
|
|
551
|
+
# StandardError, so a handler's NotImplementedError / LoadError /
|
|
552
|
+
# SystemStackError would pass it unanswered.
|
|
516
553
|
# Nothing gem-internal can be swallowed here today: handlers get
|
|
517
554
|
# no server_context, so MCP::CancelledError never originates
|
|
518
555
|
# inside this method. Revisit if cancellation is ever plumbed in.
|
|
@@ -606,7 +643,17 @@ module ClaudeAgentSDK
|
|
|
606
643
|
#
|
|
607
644
|
# @param name [String] Unique identifier for the tool
|
|
608
645
|
# @param description [String] Human-readable description
|
|
609
|
-
# @param input_schema [Hash] Schema defining input parameters
|
|
646
|
+
# @param input_schema [Hash] Schema defining input parameters: a full JSON
|
|
647
|
+
# Schema (+{ type: 'object', properties: ... }+), or the shorthand
|
|
648
|
+
# +{ name: type }+, in which every parameter is required and each type is
|
|
649
|
+
# +String+ / +:string+, +Integer+ / +:integer+, +Float+ / +:float+ /
|
|
650
|
+
# +:number+, +TrueClass+ / +FalseClass+ / +:boolean+, +Array+ / +:array+
|
|
651
|
+
# or +Hash+ / +:object+
|
|
652
|
+
# @param annotations [Hash, nil] MCP tool annotations (+title+,
|
|
653
|
+
# +readOnlyHint+, ...). +maxResultSizeChars+ is also forwarded as
|
|
654
|
+
# +_meta['anthropic/maxResultSizeChars']+, the form the CLI reads
|
|
655
|
+
# @param meta [Hash, nil] The tool's +_meta+. Merged with the size hint
|
|
656
|
+
# derived from +annotations+; a key given here wins
|
|
610
657
|
# @param handler [Proc] Block that implements the tool logic. It returns a
|
|
611
658
|
# String, sent to Claude as a single text block, or a Hash with a
|
|
612
659
|
# +:content+ Array of MCP content blocks plus optional +:is_error+ /
|
|
@@ -641,11 +688,16 @@ module ClaudeAgentSDK
|
|
|
641
688
|
def self.create_tool(name, description, input_schema, annotations: nil, meta: nil, &handler)
|
|
642
689
|
raise ArgumentError, 'Block required for tool handler' unless handler
|
|
643
690
|
|
|
644
|
-
# Auto-populate _meta with maxResultSizeChars from annotations if present
|
|
691
|
+
# Auto-populate _meta with maxResultSizeChars from annotations if present.
|
|
692
|
+
# An explicit meta: is merged with that hint rather than replacing it (an
|
|
693
|
+
# unrelated key used to drop it); a size key the caller sets itself wins,
|
|
694
|
+
# in either spelling — adding the String key beside a Symbol one would
|
|
695
|
+
# put the same JSON key in the frame twice.
|
|
696
|
+
size_key = 'anthropic/maxResultSizeChars'
|
|
645
697
|
resolved_meta = meta
|
|
646
|
-
if
|
|
698
|
+
if annotations.is_a?(Hash) && (meta.nil? || (meta.is_a?(Hash) && meta.keys.none? { |key| key.to_s == size_key }))
|
|
647
699
|
max_chars = annotations[:maxResultSizeChars] || annotations['maxResultSizeChars']
|
|
648
|
-
resolved_meta = {
|
|
700
|
+
resolved_meta = { size_key => max_chars }.merge(meta || {}) if max_chars
|
|
649
701
|
end
|
|
650
702
|
|
|
651
703
|
# tools/call arrives from the CLI with the name as a JSON String; the mcp
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'fiber_boundary'
|
|
4
|
+
require_relative 'message_parser'
|
|
5
|
+
require_relative 'query'
|
|
6
|
+
require_relative 'session_resume'
|
|
7
|
+
require_relative 'subprocess_cli_transport'
|
|
8
|
+
|
|
9
|
+
module ClaudeAgentSDK
|
|
10
|
+
# The three things every dispatch of a session's user code needs — its
|
|
11
|
+
# resolved observers, where callbacks run (callback_scheduling) and the
|
|
12
|
+
# middleware around them (callback_wrapper) — held in one place, so that
|
|
13
|
+
# no call site threads them by hand. A context holder, nothing deeper: each
|
|
14
|
+
# method is the module function it names, called with the triple.
|
|
15
|
+
#
|
|
16
|
+
# Scheduling and wrapper are fixed for the life of the object. The
|
|
17
|
+
# observers can be replaced, and each call below reads the ones there are
|
|
18
|
+
# at that moment: query() never replaces them; Client keeps one Dispatch
|
|
19
|
+
# for its lifetime and puts in the observers it resolves on each connect,
|
|
20
|
+
# so a receive loop still running from before a reconnect notifies the
|
|
21
|
+
# current ones.
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
class Dispatch
|
|
25
|
+
# For what takes the pair as its own arguments (Query, the transcript
|
|
26
|
+
# mirror batcher).
|
|
27
|
+
attr_reader :scheduling, :wrapper
|
|
28
|
+
|
|
29
|
+
# Replace the observers, with a new Array: the one being replaced may be
|
|
30
|
+
# in the middle of a notification, and is never changed in place.
|
|
31
|
+
attr_writer :observers
|
|
32
|
+
|
|
33
|
+
def initialize(observers, scheduling:, wrapper:)
|
|
34
|
+
@observers = observers
|
|
35
|
+
@scheduling = scheduling
|
|
36
|
+
@wrapper = wrapper
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# See ClaudeAgentSDK.notify_observers.
|
|
40
|
+
def notify(method, *)
|
|
41
|
+
ClaudeAgentSDK.notify_observers(@observers, method, *, scheduling: @scheduling, wrapper: @wrapper)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# See ClaudeAgentSDK.observing_prompt_stream.
|
|
45
|
+
def observing_stream(prompt)
|
|
46
|
+
ClaudeAgentSDK.observing_prompt_stream(prompt, @observers, scheduling: @scheduling, wrapper: @wrapper)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# See FiberBoundary.invoke_iteration.
|
|
50
|
+
def invoke_iteration(block, message)
|
|
51
|
+
FiberBoundary.invoke_iteration(block, message, scheduling: @scheduling, wrapper: @wrapper)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The resources of one session and what is done with them: acquiring the
|
|
56
|
+
# transport, the Query on top of it and (for a store-backed resume) the
|
|
57
|
+
# materialized config dir; writing prompts; delivering messages; disposing
|
|
58
|
+
# of all three.
|
|
59
|
+
#
|
|
60
|
+
# This is implementation shared by the two entry points, ClaudeAgentSDK.query
|
|
61
|
+
# and Client, not a lifecycle of its own. When to connect, what observers are
|
|
62
|
+
# told, which error wins and whether stdin is closed after a prompt differ
|
|
63
|
+
# between the two and stay with them; so nothing here notifies on_error or
|
|
64
|
+
# on_close, and nothing here rescues.
|
|
65
|
+
#
|
|
66
|
+
# @api private
|
|
67
|
+
class SessionAssembly
|
|
68
|
+
# For #write_prompt: stamp with the verbatim_prompts value #connect captured.
|
|
69
|
+
CAPTURED = :captured
|
|
70
|
+
|
|
71
|
+
# The session's control-protocol handler: nil before #connect and after
|
|
72
|
+
# #close_resources. The only state that is read from outside.
|
|
73
|
+
attr_reader :query_handler
|
|
74
|
+
|
|
75
|
+
# Acquires nothing. +transport_source+ says where the transport comes from:
|
|
76
|
+
#
|
|
77
|
+
# { instance: transport } one the caller built (query(transport:))
|
|
78
|
+
# { class: klass, args: kwargs } one to construct as klass.new(options, **kwargs)
|
|
79
|
+
#
|
|
80
|
+
# +configured_options+ are the options after can_use_tool routing and
|
|
81
|
+
# validation; #connect may replace them with a copy repointed at a
|
|
82
|
+
# materialized resume.
|
|
83
|
+
def initialize(configured_options, dispatch:, transport_source:)
|
|
84
|
+
@options = configured_options
|
|
85
|
+
@dispatch = dispatch
|
|
86
|
+
@transport_source = transport_source
|
|
87
|
+
@transport = nil
|
|
88
|
+
@query_handler = nil
|
|
89
|
+
@materialized = nil
|
|
90
|
+
@verbatim_prompts = false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Acquire, in order: the materialized resume (when it applies) and the
|
|
94
|
+
# options repointed at it, the transport, its connection, the Query, the
|
|
95
|
+
# transcript mirror (before the read loop starts, so that it sees every
|
|
96
|
+
# transcript_mirror frame), the read loop, the handshake.
|
|
97
|
+
#
|
|
98
|
+
# Each resource is recorded before the next step that can fail and nothing
|
|
99
|
+
# is rolled back here: whatever a step raises propagates, and the caller's
|
|
100
|
+
# own rescue calls #close_resources.
|
|
101
|
+
def connect
|
|
102
|
+
@transport = acquire_transport
|
|
103
|
+
@transport.connect
|
|
104
|
+
|
|
105
|
+
# Read once, here: after the transport connected (its #connect may still
|
|
106
|
+
# change the options it was given) and before the Query is built. The
|
|
107
|
+
# Query stamps streamed prompts with this value, and #write_prompt /
|
|
108
|
+
# #write_message stamp theirs with it too unless told otherwise, so one
|
|
109
|
+
# session marks all of them alike.
|
|
110
|
+
@verbatim_prompts = @options.verbatim_prompts?
|
|
111
|
+
@query_handler = build_query_handler
|
|
112
|
+
install_transcript_mirror
|
|
113
|
+
|
|
114
|
+
@query_handler.start
|
|
115
|
+
@query_handler.initialize_protocol
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Write a String prompt as one user message. The caller notifies
|
|
119
|
+
# on_user_prompt first: the order is its own, and so is what may happen to
|
|
120
|
+
# the options in between.
|
|
121
|
+
#
|
|
122
|
+
# verbatim: CAPTURED the value #connect captured (Client)
|
|
123
|
+
# verbatim: :current read from the options the session runs on, now
|
|
124
|
+
# (query(): after its on_user_prompt notification)
|
|
125
|
+
# verbatim: true / false that value
|
|
126
|
+
#
|
|
127
|
+
# The options the session runs on are the repointed copy when a
|
|
128
|
+
# store-backed resume was materialized, the configured ones otherwise.
|
|
129
|
+
def write_prompt(prompt, session_id:, verbatim: CAPTURED)
|
|
130
|
+
message = {
|
|
131
|
+
type: 'user',
|
|
132
|
+
message: { role: 'user', content: prompt },
|
|
133
|
+
parent_tool_use_id: nil,
|
|
134
|
+
session_id: session_id
|
|
135
|
+
}
|
|
136
|
+
writeln(Query.serialize_user_message(message, verbatim_value(verbatim)))
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Write one user message that already has its shape (a Hash, or a JSONL
|
|
140
|
+
# String), stamped with the captured verbatim_prompts value. Serialized
|
|
141
|
+
# first: a message that cannot be marked raises before anything is written.
|
|
142
|
+
def write_message(message)
|
|
143
|
+
writeln(Query.serialize_user_message(message, @verbatim_prompts))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Stream an Enumerable prompt as the session's input, in the background.
|
|
147
|
+
# The task is tracked on the Query, so closing it stops the stream;
|
|
148
|
+
# Query#stream_input closes stdin once the stream is exhausted and its
|
|
149
|
+
# last run is over, and swallows the stream's own errors with a warning.
|
|
150
|
+
# Observers get on_user_prompt for each user message before it is written.
|
|
151
|
+
def stream_prompt_in_background(prompt)
|
|
152
|
+
handler = @query_handler
|
|
153
|
+
observed = @dispatch.observing_stream(prompt)
|
|
154
|
+
handler.spawn_task { handler.stream_input(observed) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Deliver the session's messages to +block+: parse each frame, notify
|
|
158
|
+
# on_message, then invoke the block across the FiberBoundary. Returns the
|
|
159
|
+
# value of the block's `break` when it broke. With until_result: true the
|
|
160
|
+
# ResultMessage is the last message delivered.
|
|
161
|
+
#
|
|
162
|
+
# Loop control stays on this fiber, the one that dequeues: both breaks
|
|
163
|
+
# happen here, never inside the hop (a `break` in a proc on another thread
|
|
164
|
+
# raises LocalJumpError; Dispatch#invoke_iteration hands it back as a
|
|
165
|
+
# Break). Errors propagate; notifying on_error is the caller's.
|
|
166
|
+
def deliver(block, until_result: false)
|
|
167
|
+
@query_handler.receive_messages do |data|
|
|
168
|
+
message = MessageParser.parse(data)
|
|
169
|
+
next unless message
|
|
170
|
+
|
|
171
|
+
@dispatch.notify(:on_message, message)
|
|
172
|
+
signal = @dispatch.invoke_iteration(block, message)
|
|
173
|
+
break signal.value if signal.is_a?(FiberBoundary::Break)
|
|
174
|
+
break if until_result && message.is_a?(ResultMessage)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Dispose of whatever #connect acquired, however far it got: close the
|
|
179
|
+
# Query (which flushes the transcript mirror and closes the transport),
|
|
180
|
+
# close the transport, decide what happens to the materialized resume dir.
|
|
181
|
+
#
|
|
182
|
+
# always_close_transport: true the transport is closed in an ensure of
|
|
183
|
+
# its own, also when closing the Query
|
|
184
|
+
# raised (Client)
|
|
185
|
+
# always_close_transport: false the transport is closed only when no
|
|
186
|
+
# Query was built (query())
|
|
187
|
+
#
|
|
188
|
+
# Transport#close is idempotent, so the second close is harmless. The
|
|
189
|
+
# nested ensures run every later step when an earlier one raises, and the
|
|
190
|
+
# last error raised is the one that propagates.
|
|
191
|
+
#
|
|
192
|
+
# A block, when given, is called once both closes are behind and before
|
|
193
|
+
# the materialized dir is dealt with — also when a close raised. From
|
|
194
|
+
# there on nothing of the session can be used any more, while removing
|
|
195
|
+
# the directory can still take a while (it retries, sleeping, when the
|
|
196
|
+
# directory is busy) and lets other tasks run meanwhile: the caller marks
|
|
197
|
+
# itself disconnected in the block, so that calls made in that window are
|
|
198
|
+
# refused instead of reaching a session that is half gone.
|
|
199
|
+
def close_resources(always_close_transport:)
|
|
200
|
+
# Kept past the nil-out below: whether the mirror dropped batches is
|
|
201
|
+
# final only after #close ran its last flush.
|
|
202
|
+
handler = @query_handler
|
|
203
|
+
begin
|
|
204
|
+
handler&.close
|
|
205
|
+
ensure
|
|
206
|
+
@query_handler = nil
|
|
207
|
+
begin
|
|
208
|
+
@transport&.close if always_close_transport || handler.nil?
|
|
209
|
+
ensure
|
|
210
|
+
@transport = nil
|
|
211
|
+
yield if block_given?
|
|
212
|
+
dispose_of_materialized_resume(handler)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
private
|
|
218
|
+
|
|
219
|
+
# An injected transport was built before any repointing could reach it,
|
|
220
|
+
# so nothing is materialized for it. A class is constructed after
|
|
221
|
+
# materialization, with the repointed options.
|
|
222
|
+
def acquire_transport
|
|
223
|
+
return @transport_source.fetch(:instance) if @transport_source.key?(:instance)
|
|
224
|
+
|
|
225
|
+
transport_class = @transport_source.fetch(:class)
|
|
226
|
+
materialize_resume if @options.session_store && spawns_cli_locally?(transport_class)
|
|
227
|
+
transport_class.new(@options, **@transport_source.fetch(:args))
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The materialized config dir and --resume reach a transport only through
|
|
231
|
+
# the options it is constructed with, and mean something only to one that
|
|
232
|
+
# spawns the CLI on this host with them: SubprocessCLITransport or a
|
|
233
|
+
# subclass of it (ancestry, not identity). The source may be a duck-typed
|
|
234
|
+
# factory rather than a Class.
|
|
235
|
+
def spawns_cli_locally?(transport_class)
|
|
236
|
+
transport_class.is_a?(Class) && transport_class <= SubprocessCLITransport
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Resume-from-store: load the session from the store into a temp
|
|
240
|
+
# CLAUDE_CONFIG_DIR and repoint the options at it (env + --resume). The
|
|
241
|
+
# directory is recorded before the repointing, so a failure there still
|
|
242
|
+
# leaves it to #close_resources.
|
|
243
|
+
def materialize_resume
|
|
244
|
+
@materialized = SessionResume.materialize_resume_session(@options)
|
|
245
|
+
@options = SessionResume.apply_materialized_options(@options, @materialized) if @materialized
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# The one place a Query is built from options. Streaming mode with the
|
|
249
|
+
# control protocol, always (as in the Python SDK): agents travel in the
|
|
250
|
+
# initialize request rather than in CLI arguments, clear of ARG_MAX.
|
|
251
|
+
def build_query_handler
|
|
252
|
+
Query.new(
|
|
253
|
+
transport: @transport,
|
|
254
|
+
is_streaming_mode: true,
|
|
255
|
+
can_use_tool: @options.can_use_tool,
|
|
256
|
+
hooks: ClaudeAgentSDK.convert_hooks_to_internal_format(@options.hooks),
|
|
257
|
+
sdk_mcp_servers: ClaudeAgentSDK.extract_sdk_mcp_servers(@options.mcp_servers),
|
|
258
|
+
agents: @options.agents,
|
|
259
|
+
exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(@options.system_prompt),
|
|
260
|
+
system_prompt_snapshot: ClaudeAgentSDK.extract_system_prompt_snapshot(@options.system_prompt),
|
|
261
|
+
skills: @options.skills,
|
|
262
|
+
forward_subagent_text: @options.forward_subagent_text?,
|
|
263
|
+
agent_progress_summaries: @options.agent_progress_summaries,
|
|
264
|
+
callback_scheduling: @dispatch.scheduling,
|
|
265
|
+
callback_wrapper: @dispatch.wrapper,
|
|
266
|
+
verbatim_prompts: @verbatim_prompts,
|
|
267
|
+
run_end_ceiling_ms: Query.run_end_ceiling_ms(@options.env)
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Mirror transcripts to the session_store, if one is configured.
|
|
272
|
+
def install_transcript_mirror
|
|
273
|
+
return unless @options.session_store
|
|
274
|
+
|
|
275
|
+
handler = @query_handler
|
|
276
|
+
handler.set_transcript_mirror_batcher(
|
|
277
|
+
SessionResume.build_mirror_batcher(
|
|
278
|
+
store: @options.session_store,
|
|
279
|
+
env: @options.env,
|
|
280
|
+
on_error: ->(key, message) { handler.report_mirror_error(key, message) },
|
|
281
|
+
eager: @options.session_store_flush.to_s == 'eager',
|
|
282
|
+
callback_wrapper: @dispatch.wrapper
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def verbatim_value(verbatim)
|
|
288
|
+
case verbatim
|
|
289
|
+
when CAPTURED then @verbatim_prompts
|
|
290
|
+
when :current then @options.verbatim_prompts?
|
|
291
|
+
else verbatim
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def writeln(string)
|
|
296
|
+
@transport.write(string.end_with?("\n") ? string : "#{string}\n")
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The materialized resume dir holds a redacted .credentials.json copy, so
|
|
300
|
+
# it is removed once the subprocess has exited — unless the mirror dropped
|
|
301
|
+
# batches: the store copy is then incomplete and the dir holds the only
|
|
302
|
+
# copy of the dropped turns, so it is preserved (scrubbed of credentials)
|
|
303
|
+
# with a warning instead.
|
|
304
|
+
def dispose_of_materialized_resume(handler)
|
|
305
|
+
return unless @materialized
|
|
306
|
+
|
|
307
|
+
handler&.mirror_batches_dropped? ? @materialized.preserve_transcripts : @materialized.cleanup
|
|
308
|
+
@materialized = nil
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|