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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require_relative 'errors'
|
|
5
5
|
require_relative 'types'
|
|
6
|
+
require_relative 'option_forms'
|
|
6
7
|
|
|
7
8
|
module ClaudeAgentSDK
|
|
8
9
|
# Builds the CLI argv array from a ClaudeAgentOptions instance.
|
|
@@ -68,42 +69,32 @@ module ClaudeAgentSDK
|
|
|
68
69
|
|
|
69
70
|
private
|
|
70
71
|
|
|
72
|
+
# What the option stands for, typed or as a Hash, is OptionForms' to say;
|
|
73
|
+
# a kind that is none of these (a preset without an append, a Hash the
|
|
74
|
+
# SDK does not recognize, ...) sends no prompt flag at all.
|
|
71
75
|
def append_system_prompt(cmd)
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
prompt = OptionForms.system_prompt(@options.system_prompt)
|
|
77
|
+
case prompt.kind
|
|
78
|
+
when :empty
|
|
74
79
|
# When nil, pass empty string to ensure predictable behavior without default Claude Code system prompt
|
|
75
80
|
cmd.push('--system-prompt', '')
|
|
76
|
-
when
|
|
77
|
-
|
|
78
|
-
when SystemPromptFile
|
|
79
|
-
cmd.push('--system-prompt-file', @options.system_prompt.path)
|
|
80
|
-
when SystemPromptCustom
|
|
81
|
-
# The object form of a String prompt; snapshot travels on the
|
|
81
|
+
when :text
|
|
82
|
+
# A String, or its object form (custom); snapshot travels on the
|
|
82
83
|
# initialize request, not as a CLI flag.
|
|
83
|
-
cmd.push('--system-prompt', custom_prompt_text(
|
|
84
|
-
when
|
|
84
|
+
cmd.push('--system-prompt', custom_prompt_text(prompt.value))
|
|
85
|
+
when :file
|
|
86
|
+
cmd.push('--system-prompt-file', path_string(prompt.value))
|
|
87
|
+
when :append
|
|
85
88
|
# Preset activates the default Claude Code system prompt by not passing --system-prompt ""
|
|
86
89
|
# Only --append-system-prompt is passed if append text is provided
|
|
87
|
-
cmd.push('--append-system-prompt',
|
|
88
|
-
when Hash
|
|
89
|
-
append_hash_system_prompt(cmd, @options.system_prompt)
|
|
90
|
+
cmd.push('--append-system-prompt', prompt.value)
|
|
90
91
|
end
|
|
91
92
|
end
|
|
92
93
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
prompt_path = prompt_hash[:path] || prompt_hash['path']
|
|
98
|
-
cmd.push('--system-prompt-file', prompt_path) if prompt_path
|
|
99
|
-
when 'custom'
|
|
100
|
-
prompt = prompt_hash.fetch(:prompt) { prompt_hash['prompt'] }
|
|
101
|
-
cmd.push('--system-prompt', custom_prompt_text(prompt))
|
|
102
|
-
when 'preset'
|
|
103
|
-
append = prompt_hash[:append] || prompt_hash['append']
|
|
104
|
-
# Preset activates the default Claude Code system prompt by not passing --system-prompt ""
|
|
105
|
-
cmd.push('--append-system-prompt', append) if append
|
|
106
|
-
end
|
|
94
|
+
# A path as the String the command line takes. A Pathname (anything that
|
|
95
|
+
# answers #to_path) is converted; every other value is returned as it is.
|
|
96
|
+
def path_string(path)
|
|
97
|
+
path.respond_to?(:to_path) ? path.to_path : path
|
|
107
98
|
end
|
|
108
99
|
|
|
109
100
|
# A custom prompt is always forwarded, even when empty (an empty String
|
|
@@ -129,8 +120,12 @@ module ClaudeAgentSDK
|
|
|
129
120
|
# Each listed name is validated before being formatted into a rule (see
|
|
130
121
|
# #validate_skill_name). Both SDKs reject non-list, non-'all' shapes
|
|
131
122
|
# loudly; this raises ArgumentError where Python raises TypeError.
|
|
123
|
+
#
|
|
124
|
+
# allowed_tools, like disallowed_tools, add_dirs and extra_args below, is
|
|
125
|
+
# nil when it was set to nil after construction (the readers are signed
|
|
126
|
+
# nilable); each use site reads nil as the constructor's default.
|
|
132
127
|
def skills_defaults
|
|
133
|
-
allowed_tools = @options.allowed_tools.dup
|
|
128
|
+
allowed_tools = (@options.allowed_tools || []).dup
|
|
134
129
|
setting_sources = @options.setting_sources&.dup
|
|
135
130
|
skills = @options.skills
|
|
136
131
|
return [allowed_tools, setting_sources] if skills.nil?
|
|
@@ -210,7 +205,8 @@ module ClaudeAgentSDK
|
|
|
210
205
|
end
|
|
211
206
|
|
|
212
207
|
def append_disallowed_tools(cmd)
|
|
213
|
-
|
|
208
|
+
disallowed_tools = @options.disallowed_tools || []
|
|
209
|
+
cmd.push('--disallowedTools', disallowed_tools.join(',')) unless disallowed_tools.empty?
|
|
214
210
|
end
|
|
215
211
|
|
|
216
212
|
def append_max_turns(cmd)
|
|
@@ -301,16 +297,14 @@ module ClaudeAgentSDK
|
|
|
301
297
|
settings_is_path = false
|
|
302
298
|
|
|
303
299
|
if @options.settings
|
|
304
|
-
if @options.settings.
|
|
300
|
+
if @options.settings.respond_to?(:to_path)
|
|
301
|
+
# A Pathname names a settings file; it is never tried as inline JSON.
|
|
302
|
+
settings_hash, settings_is_path = settings_file(cmd, @options.settings.to_path)
|
|
303
|
+
elsif @options.settings.is_a?(String)
|
|
305
304
|
begin
|
|
306
305
|
settings_hash = JSON.parse(@options.settings)
|
|
307
306
|
rescue JSON::ParserError
|
|
308
|
-
|
|
309
|
-
settings_is_path = true
|
|
310
|
-
cmd.push('--settings', @options.settings)
|
|
311
|
-
else
|
|
312
|
-
settings_hash = load_settings_file(@options.settings)
|
|
313
|
-
end
|
|
307
|
+
settings_hash, settings_is_path = settings_file(cmd, @options.settings)
|
|
314
308
|
end
|
|
315
309
|
elsif @options.settings.is_a?(Hash)
|
|
316
310
|
settings_hash = @options.settings.dup
|
|
@@ -318,22 +312,31 @@ module ClaudeAgentSDK
|
|
|
318
312
|
end
|
|
319
313
|
|
|
320
314
|
if !settings_is_path && !@options.sandbox.nil?
|
|
321
|
-
|
|
315
|
+
# The option replaces any sandbox section the settings carry. Settings
|
|
316
|
+
# read from JSON spell that key as a String; left next to the Symbol
|
|
317
|
+
# key below it would be written twice (json 3.x raises on that).
|
|
318
|
+
settings_hash = settings_hash.reject { |key, _| key.to_s == 'sandbox' }
|
|
319
|
+
settings_hash[:sandbox] = OptionForms.sandbox(@options.sandbox)
|
|
322
320
|
end
|
|
323
321
|
|
|
324
322
|
cmd.push('--settings', JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty?
|
|
325
323
|
end
|
|
326
324
|
|
|
325
|
+
# --settings for a settings file, as [settings_hash, settings_is_path].
|
|
326
|
+
# Without a sandbox option the path itself is passed and the CLI reads
|
|
327
|
+
# the file. With one, the file is read here, so that the option can be
|
|
328
|
+
# folded into its content.
|
|
329
|
+
def settings_file(cmd, path)
|
|
330
|
+
return [load_settings_file(path), false] unless @options.sandbox.nil?
|
|
331
|
+
|
|
332
|
+
cmd.push('--settings', path)
|
|
333
|
+
[{}, true]
|
|
334
|
+
end
|
|
335
|
+
|
|
327
336
|
def append_budget(cmd)
|
|
328
337
|
cmd.push('--max-budget-usd', @options.max_budget_usd.to_s) if @options.max_budget_usd
|
|
329
338
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
total = if @options.task_budget.is_a?(TaskBudget)
|
|
333
|
-
@options.task_budget.total
|
|
334
|
-
else
|
|
335
|
-
@options.task_budget[:total] || @options.task_budget['total']
|
|
336
|
-
end
|
|
339
|
+
total = OptionForms.task_budget_total(@options.task_budget)
|
|
337
340
|
cmd.push('--task-budget', total.to_s) if total
|
|
338
341
|
end
|
|
339
342
|
|
|
@@ -345,16 +348,16 @@ module ClaudeAgentSDK
|
|
|
345
348
|
# max_thinking_tokens fallback.
|
|
346
349
|
def append_thinking(cmd)
|
|
347
350
|
if @options.thinking
|
|
348
|
-
|
|
349
|
-
case type
|
|
351
|
+
thinking = OptionForms.thinking(@options.thinking)
|
|
352
|
+
case thinking.type
|
|
350
353
|
when 'adaptive'
|
|
351
354
|
cmd.push('--thinking', 'adaptive')
|
|
352
|
-
append_thinking_display(cmd, display)
|
|
355
|
+
append_thinking_display(cmd, thinking.display)
|
|
353
356
|
when 'enabled'
|
|
354
|
-
raise ArgumentError, "thinking type 'enabled' requires budget_tokens" if
|
|
357
|
+
raise ArgumentError, "thinking type 'enabled' requires budget_tokens" if thinking.budget_tokens.nil?
|
|
355
358
|
|
|
356
|
-
cmd.push('--max-thinking-tokens',
|
|
357
|
-
append_thinking_display(cmd, display)
|
|
359
|
+
cmd.push('--max-thinking-tokens', thinking.budget_tokens.to_s)
|
|
360
|
+
append_thinking_display(cmd, thinking.display)
|
|
358
361
|
when 'disabled'
|
|
359
362
|
cmd.push('--thinking', 'disabled')
|
|
360
363
|
else
|
|
@@ -365,20 +368,6 @@ module ClaudeAgentSDK
|
|
|
365
368
|
end
|
|
366
369
|
end
|
|
367
370
|
|
|
368
|
-
# Explicit class dispatch — never respond_to? probes (Kernel#display
|
|
369
|
-
# exists on every object and PRINTS the receiver to $stdout).
|
|
370
|
-
def thinking_fields(thinking)
|
|
371
|
-
case thinking
|
|
372
|
-
when Hash
|
|
373
|
-
type = (thinking[:type] || thinking['type'])&.to_s
|
|
374
|
-
[type, thinking[:budget_tokens] || thinking['budget_tokens'], thinking[:display] || thinking['display']]
|
|
375
|
-
when ThinkingConfigAdaptive then [thinking.type, nil, thinking.display]
|
|
376
|
-
when ThinkingConfigEnabled then [thinking.type, thinking.budget_tokens, thinking.display]
|
|
377
|
-
when ThinkingConfigDisabled then [thinking.type, nil, nil]
|
|
378
|
-
else [nil, nil, nil] # falls into append_thinking's else -> ArgumentError
|
|
379
|
-
end
|
|
380
|
-
end
|
|
381
|
-
|
|
382
371
|
# `--thinking-display` toggles between `"summarized"` (visible thinking
|
|
383
372
|
# text) and `"omitted"` (empty thinking, signature only). Current models default
|
|
384
373
|
# to `"omitted"`, so pass `display: "summarized"` to see reasoning.
|
|
@@ -404,31 +393,27 @@ module ClaudeAgentSDK
|
|
|
404
393
|
def append_tools(cmd)
|
|
405
394
|
return if @options.tools.nil?
|
|
406
395
|
|
|
407
|
-
|
|
396
|
+
# The preset, typed or as a Hash, is OptionForms' to recognize; every
|
|
397
|
+
# other value comes back as it was given and is only encoded here.
|
|
398
|
+
tools = OptionForms.tools(@options.tools)
|
|
399
|
+
case tools
|
|
400
|
+
when OptionForms::DEFAULT_TOOLS
|
|
401
|
+
cmd.push('--tools', 'default')
|
|
408
402
|
when Array
|
|
409
|
-
tools_value =
|
|
403
|
+
tools_value = tools.empty? ? '' : tools.join(',')
|
|
410
404
|
cmd.push('--tools', tools_value)
|
|
411
|
-
when
|
|
412
|
-
|
|
405
|
+
when String
|
|
406
|
+
# The CLI's own syntax ("Read,Grep", "default", ""): passed as written.
|
|
407
|
+
cmd.push('--tools', tools)
|
|
413
408
|
when Hash
|
|
414
|
-
|
|
415
|
-
cmd.push('--tools', 'default')
|
|
416
|
-
else
|
|
417
|
-
cmd.push('--tools', JSON.generate(@options.tools))
|
|
418
|
-
end
|
|
409
|
+
cmd.push('--tools', JSON.generate(tools))
|
|
419
410
|
end
|
|
420
411
|
end
|
|
421
412
|
|
|
422
413
|
def append_output_format(cmd)
|
|
423
414
|
return unless @options.output_format
|
|
424
415
|
|
|
425
|
-
schema =
|
|
426
|
-
@options.output_format[:schema]
|
|
427
|
-
elsif @options.output_format.is_a?(Hash) && @options.output_format['type'] == 'json_schema'
|
|
428
|
-
@options.output_format['schema']
|
|
429
|
-
else
|
|
430
|
-
@options.output_format
|
|
431
|
-
end
|
|
416
|
+
schema = OptionForms.output_schema(@options.output_format)
|
|
432
417
|
# A json_schema output_format with a nil/absent schema must skip the
|
|
433
418
|
# flag — `--json-schema null` is rejected by the CLI (Python guards
|
|
434
419
|
# `schema is not None`).
|
|
@@ -439,31 +424,21 @@ module ClaudeAgentSDK
|
|
|
439
424
|
end
|
|
440
425
|
|
|
441
426
|
def append_additional_dirs(cmd)
|
|
442
|
-
@options.add_dirs.each { |dir| cmd.push('--add-dir', dir.to_s) }
|
|
427
|
+
(@options.add_dirs || []).each { |dir| cmd.push('--add-dir', dir.to_s) }
|
|
443
428
|
end
|
|
444
429
|
|
|
445
430
|
def append_mcp_servers(cmd)
|
|
446
431
|
return unless @options.mcp_servers && !@options.mcp_servers.empty?
|
|
447
432
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
# either key style (and a Symbol :sdk type). The live instance is
|
|
456
|
-
# never serialized — JSON.generate would raise on it or leak its
|
|
457
|
-
# #to_s onto the command line.
|
|
458
|
-
servers_for_cli[name] = if config.is_a?(Hash) && (config[:type] || config['type']).to_s == 'sdk'
|
|
459
|
-
config.except(:instance, 'instance')
|
|
460
|
-
else
|
|
461
|
-
config
|
|
462
|
-
end
|
|
463
|
-
end
|
|
464
|
-
cmd.push('--mcp-config', JSON.generate({ mcpServers: servers_for_cli })) unless servers_for_cli.empty?
|
|
433
|
+
# A Hash of servers comes back with typed configs as their wire Hashes
|
|
434
|
+
# and without the live instance of an SDK server, which is never
|
|
435
|
+
# serialized: JSON.generate would raise on it or leak its #to_s onto
|
|
436
|
+
# the command line. Any other value is a path or JSON text.
|
|
437
|
+
servers = OptionForms.mcp_servers(@options.mcp_servers)
|
|
438
|
+
if servers.is_a?(Hash)
|
|
439
|
+
cmd.push('--mcp-config', JSON.generate({ mcpServers: servers })) unless servers.empty?
|
|
465
440
|
else
|
|
466
|
-
cmd.push('--mcp-config',
|
|
441
|
+
cmd.push('--mcp-config', servers.to_s)
|
|
467
442
|
end
|
|
468
443
|
end
|
|
469
444
|
|
|
@@ -482,17 +457,17 @@ module ClaudeAgentSDK
|
|
|
482
457
|
def append_plugins(cmd)
|
|
483
458
|
return unless @options.plugins && !@options.plugins.empty?
|
|
484
459
|
|
|
485
|
-
@options.plugins.each do |
|
|
486
|
-
|
|
487
|
-
plugin_type = plugin_config[:type] || plugin_config['type']
|
|
488
|
-
plugin_path = plugin_config[:path] || plugin_config['path']
|
|
460
|
+
@options.plugins.each do |entry|
|
|
461
|
+
plugin = OptionForms.plugin(entry)
|
|
489
462
|
|
|
490
|
-
unless %w[local plugin].include?(
|
|
491
|
-
|
|
463
|
+
unless %w[local plugin].include?(plugin.type_tag)
|
|
464
|
+
# raw_type looks the type up again, as it was written; asked for
|
|
465
|
+
# only here, so an accepted plugin has its type read once.
|
|
466
|
+
raise ArgumentError, "Unsupported plugin type: #{plugin.raw_type.inspect}"
|
|
492
467
|
end
|
|
493
|
-
next unless
|
|
468
|
+
next unless plugin.path
|
|
494
469
|
|
|
495
|
-
cmd.push('--plugin-dir',
|
|
470
|
+
cmd.push('--plugin-dir', path_string(plugin.path))
|
|
496
471
|
end
|
|
497
472
|
end
|
|
498
473
|
|
|
@@ -503,7 +478,7 @@ module ClaudeAgentSDK
|
|
|
503
478
|
end
|
|
504
479
|
|
|
505
480
|
def append_extra_args(cmd)
|
|
506
|
-
@options.extra_args.each do |flag, value|
|
|
481
|
+
(@options.extra_args || {}).each do |flag, value|
|
|
507
482
|
unless EXTRA_ARG_FLAG_REGEXP.match?(flag)
|
|
508
483
|
raise ArgumentError, "Invalid extra_args flag name: #{flag.inspect} (expected lowercase kebab-case)"
|
|
509
484
|
end
|
|
@@ -135,6 +135,23 @@ module ClaudeAgentSDK
|
|
|
135
135
|
end
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
# What a failing user callback raises — a hook, can_use_tool, an SDK MCP
|
|
139
|
+
# tool / resource / prompt handler, or the callback_wrapper around one.
|
|
140
|
+
# The Ruby spelling of Python's `except Exception`: NotImplementedError
|
|
141
|
+
# and LoadError (both ScriptError), SystemStackError and SecurityError
|
|
142
|
+
# are not StandardErrors, so `rescue StandardError` lets them through.
|
|
143
|
+
# The control request then goes unanswered and its handler task ends
|
|
144
|
+
# with an exception Async treats as fatal for the whole reactor. Rescue
|
|
145
|
+
# `*FiberBoundary::CALLBACK_FAILURES` wherever a callback's failure is
|
|
146
|
+
# turned into the answer the CLI is waiting for.
|
|
147
|
+
#
|
|
148
|
+
# An explicit list, not `rescue Exception`: cancellation (Async::Stop,
|
|
149
|
+
# InlineCancellation) and process exits (SystemExit, SignalException —
|
|
150
|
+
# see .invoke_callback) must keep propagating. NoMemoryError stays out
|
|
151
|
+
# as well: building the answer would most likely fail again.
|
|
152
|
+
# @api private
|
|
153
|
+
CALLBACK_FAILURES = [StandardError, ScriptError, SystemStackError, SecurityError].freeze
|
|
154
|
+
|
|
138
155
|
# Carries a SystemExit / SignalException (Interrupt included) raised by
|
|
139
156
|
# a user callback out of the FiberBoundary hop — see .invoke_callback.
|
|
140
157
|
# A StandardError so the hop ends normally: a :thread worker that died
|
|
@@ -335,9 +352,35 @@ module ClaudeAgentSDK
|
|
|
335
352
|
Thread.current.report_on_exception = false
|
|
336
353
|
work.call
|
|
337
354
|
end
|
|
338
|
-
|
|
339
|
-
|
|
355
|
+
await_worker(thread, timeout)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Wait for the worker thread of one hop and return its value (or re-raise
|
|
359
|
+
# what it raised); with +timeout+, raise JoinTimeout once that many
|
|
360
|
+
# seconds have passed.
|
|
361
|
+
#
|
|
362
|
+
# Only "the thread has finished" or "the deadline has really passed" ends
|
|
363
|
+
# the wait. Under a fiber scheduler Thread#join parks the fiber, and MRI
|
|
364
|
+
# reports ANY wakeup that finds the thread alive as a timeout: join
|
|
365
|
+
# returns nil, and Thread#value returns nil with it. The wakeup need not
|
|
366
|
+
# belong to this join — one queued by an earlier hop's thread stays behind
|
|
367
|
+
# when an exception (Async::Stop, a deadline) is raised into the fiber
|
|
368
|
+
# before it is consumed, and resumes this hop instead. Trusting a single
|
|
369
|
+
# join then returned nil while the callback was still running, or raised
|
|
370
|
+
# JoinTimeout with no time passed.
|
|
371
|
+
# @api private
|
|
372
|
+
def await_worker(thread, timeout)
|
|
373
|
+
if timeout.nil?
|
|
374
|
+
nil until thread.join
|
|
375
|
+
return thread.value
|
|
376
|
+
end
|
|
340
377
|
|
|
378
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
379
|
+
until thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
|
|
380
|
+
next if Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
381
|
+
|
|
382
|
+
raise JoinTimeout, "timed out after #{timeout}s"
|
|
383
|
+
end
|
|
341
384
|
thread.value
|
|
342
385
|
end
|
|
343
386
|
|
|
@@ -7,8 +7,10 @@ module ClaudeAgentSDK
|
|
|
7
7
|
module Instrumentation
|
|
8
8
|
# OpenTelemetry observer that emits spans for Claude Agent SDK messages.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
# Span attributes follow the Langfuse and OpenInference conventions, plus a
|
|
11
|
+
# subset of the OTel gen_ai.* attributes. docs/observability.md lists every
|
|
12
|
+
# attribute and says where they differ from the OTel GenAI semantic
|
|
13
|
+
# conventions.
|
|
12
14
|
#
|
|
13
15
|
# Requires the `opentelemetry-api` gem at runtime. Users must configure
|
|
14
16
|
# `opentelemetry-sdk` and an exporter (e.g., `opentelemetry-exporter-otlp`)
|
|
@@ -49,10 +51,12 @@ module ClaudeAgentSDK
|
|
|
49
51
|
@default_attributes = default_attributes
|
|
50
52
|
@root_span = nil
|
|
51
53
|
@root_context = nil
|
|
54
|
+
@trace_started = false # an InitMessage opened a trace since this observer was created or last closed
|
|
52
55
|
@tool_spans = {} # tool_use_id => span
|
|
53
56
|
@first_user_input = nil # first user prompt of the current trace
|
|
54
57
|
@pending_prompt = nil # prompt that belongs to the NEXT trace (see on_user_prompt)
|
|
55
58
|
@last_assistant_text = nil # capture last assistant text for trace output
|
|
59
|
+
@usage_reported_message_ids = Set.new # API responses whose usage a generation span of this trace carries
|
|
56
60
|
@cost_session_id = nil
|
|
57
61
|
@last_total_cost_usd = nil
|
|
58
62
|
end
|
|
@@ -99,16 +103,25 @@ module ClaudeAgentSDK
|
|
|
99
103
|
end
|
|
100
104
|
end
|
|
101
105
|
|
|
102
|
-
#
|
|
103
|
-
# user may rescue one bad message and keep
|
|
104
|
-
# would orphan later spans and break the
|
|
105
|
-
# stays with end_trace/on_close; start_trace
|
|
106
|
-
# span from a previous trace as the
|
|
106
|
+
# With a trace open this is recording-only by design: a Client session
|
|
107
|
+
# can survive an error (the user may rescue one bad message and keep
|
|
108
|
+
# receiving), so finishing here would orphan later spans and break the
|
|
109
|
+
# next turn. Finish ownership stays with end_trace/on_close; start_trace
|
|
110
|
+
# also finishes any dangling span from a previous trace as the
|
|
111
|
+
# never-disconnected backstop.
|
|
112
|
+
#
|
|
113
|
+
# With no trace open, an error that arrives before the session's first
|
|
114
|
+
# InitMessage gets a session span of its own (record_failed_start). Once
|
|
115
|
+
# a trace has run, an error between traces stays unrecorded: the usual
|
|
116
|
+
# one is the ResultError query() raises when the CLI exits non-zero
|
|
117
|
+
# after an error result, and the trace that just ended already reports
|
|
118
|
+
# that failure.
|
|
107
119
|
def on_error(error)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
120
|
+
if @root_span
|
|
121
|
+
record_error(@root_span, error)
|
|
122
|
+
elsif !@trace_started
|
|
123
|
+
record_failed_start(error)
|
|
124
|
+
end
|
|
112
125
|
end
|
|
113
126
|
|
|
114
127
|
def on_close
|
|
@@ -120,11 +133,12 @@ module ClaudeAgentSDK
|
|
|
120
133
|
@pending_prompt = nil
|
|
121
134
|
@cost_session_id = nil
|
|
122
135
|
@last_total_cost_usd = nil
|
|
136
|
+
@trace_started = false
|
|
123
137
|
end
|
|
124
138
|
|
|
125
139
|
private
|
|
126
140
|
|
|
127
|
-
def start_trace(message)
|
|
141
|
+
def start_trace(message)
|
|
128
142
|
# A new init without an intervening ResultMessage (e.g. /clear or an
|
|
129
143
|
# interrupted turn) supersedes the current trace; finish it so it is
|
|
130
144
|
# exported instead of leaking as a never-ended span, and reset the
|
|
@@ -141,21 +155,9 @@ module ClaudeAgentSDK
|
|
|
141
155
|
# because it came first chronologically.
|
|
142
156
|
@first_user_input = @pending_prompt if @pending_prompt
|
|
143
157
|
@pending_prompt = nil
|
|
158
|
+
@trace_started = true
|
|
144
159
|
|
|
145
|
-
attrs =
|
|
146
|
-
# gen_ai semantic conventions (recognized by Langfuse, Datadog, etc.)
|
|
147
|
-
'gen_ai.system' => 'anthropic',
|
|
148
|
-
'gen_ai.request.model' => message.model,
|
|
149
|
-
# OpenInference conventions (recognized by Langfuse, Arize)
|
|
150
|
-
'openinference.span.kind' => 'AGENT',
|
|
151
|
-
'llm.model_name' => message.model,
|
|
152
|
-
'input.mime_type' => 'text/plain',
|
|
153
|
-
'output.mime_type' => 'text/plain',
|
|
154
|
-
# Langfuse: 'agent' type triggers the trace flow diagram (DAG graph)
|
|
155
|
-
'langfuse.observation.type' => 'agent',
|
|
156
|
-
# Session tracking
|
|
157
|
-
'session.id' => message.session_id
|
|
158
|
-
}.merge(@default_attributes)
|
|
160
|
+
attrs = session_span_attrs(model: message.model, session_id: message.session_id)
|
|
159
161
|
|
|
160
162
|
if message.respond_to?(:claude_code_version) && message.claude_code_version
|
|
161
163
|
attrs['claude_code.version'] = message.claude_code_version
|
|
@@ -174,6 +176,50 @@ module ClaudeAgentSDK
|
|
|
174
176
|
@root_span.set_attribute('input.value', truncate(@first_user_input))
|
|
175
177
|
end
|
|
176
178
|
|
|
179
|
+
# The attributes every session span starts with. The model and the
|
|
180
|
+
# session id come from the InitMessage, so the span of a session that
|
|
181
|
+
# failed before sending one (record_failed_start) has neither.
|
|
182
|
+
def session_span_attrs(model: nil, session_id: nil)
|
|
183
|
+
{
|
|
184
|
+
# gen_ai semantic conventions (recognized by Langfuse, Datadog, etc.)
|
|
185
|
+
'gen_ai.system' => 'anthropic',
|
|
186
|
+
'gen_ai.request.model' => model,
|
|
187
|
+
# OpenInference conventions (recognized by Langfuse, Arize)
|
|
188
|
+
'openinference.span.kind' => 'AGENT',
|
|
189
|
+
'llm.model_name' => model,
|
|
190
|
+
'input.mime_type' => 'text/plain',
|
|
191
|
+
'output.mime_type' => 'text/plain',
|
|
192
|
+
# Langfuse: 'agent' type triggers the trace flow diagram (DAG graph)
|
|
193
|
+
'langfuse.observation.type' => 'agent',
|
|
194
|
+
# Session tracking
|
|
195
|
+
'session.id' => session_id
|
|
196
|
+
}.merge(@default_attributes)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# A session that fails before its first InitMessage (the CLI cannot be
|
|
200
|
+
# found or started, initialize fails or times out, the process dies
|
|
201
|
+
# before its first frame) has no trace to record the error on, so the
|
|
202
|
+
# error gets a session span of its own, with the prompt if one was sent.
|
|
203
|
+
#
|
|
204
|
+
# The span is finished here and never becomes @root_span. query() does
|
|
205
|
+
# call on_close next, but a Client#connect that fails before the
|
|
206
|
+
# handshake never does, and an unfinished span is never exported; with
|
|
207
|
+
# no root span set, a later on_close has nothing to finish a second
|
|
208
|
+
# time. The buffers are reset so that the failed session's prompt cannot
|
|
209
|
+
# label the next trace of a reused observer.
|
|
210
|
+
def record_failed_start(error)
|
|
211
|
+
span = @tracer.start_span('claude_agent.session', attributes: compact_attrs(session_span_attrs))
|
|
212
|
+
span.set_attribute('input.value', truncate(@first_user_input)) if @first_user_input
|
|
213
|
+
record_error(span, error)
|
|
214
|
+
span.finish
|
|
215
|
+
reset_session_buffers
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def record_error(span, error)
|
|
219
|
+
span.record_exception(error)
|
|
220
|
+
span.status = OpenTelemetry::Trace::Status.error(error.message)
|
|
221
|
+
end
|
|
222
|
+
|
|
177
223
|
def handle_assistant(message)
|
|
178
224
|
return unless @root_context
|
|
179
225
|
|
|
@@ -203,7 +249,7 @@ module ClaudeAgentSDK
|
|
|
203
249
|
'gen_ai.completion' => truncate(combined_text),
|
|
204
250
|
# OpenInference: Langfuse maps output.value to the Preview Output field
|
|
205
251
|
'output.value' => truncate(combined_text)
|
|
206
|
-
}.merge(
|
|
252
|
+
}.merge(generation_usage_attrs(message))
|
|
207
253
|
|
|
208
254
|
OpenTelemetry::Context.with_current(@root_context) do
|
|
209
255
|
span = @tracer.start_span('claude_agent.generation', attributes: compact_attrs(attrs))
|
|
@@ -343,10 +389,12 @@ module ClaudeAgentSDK
|
|
|
343
389
|
|
|
344
390
|
# Clear per-trace buffers so a reused observer instance (sequential
|
|
345
391
|
# query() calls or multi-turn Client sessions) does not stamp stale
|
|
346
|
-
# input/output onto later traces
|
|
392
|
+
# input/output onto later traces, and so the message ids remembered by
|
|
393
|
+
# generation_usage_attrs never outlive their trace.
|
|
347
394
|
def reset_session_buffers
|
|
348
395
|
@first_user_input = nil
|
|
349
396
|
@last_assistant_text = nil
|
|
397
|
+
@usage_reported_message_ids.clear
|
|
350
398
|
end
|
|
351
399
|
|
|
352
400
|
def record_retry_event(message)
|
|
@@ -397,6 +445,20 @@ module ClaudeAgentSDK
|
|
|
397
445
|
}
|
|
398
446
|
end
|
|
399
447
|
|
|
448
|
+
# gen_ai.usage.* attributes for one generation span. The CLI sends one
|
|
449
|
+
# assistant frame per content block, and every frame of an API response
|
|
450
|
+
# repeats that response's message id and usage snapshot, so putting the
|
|
451
|
+
# snapshot on every span counted the response's input, cache-read and
|
|
452
|
+
# cache-creation tokens once per block. Only the first span of a message
|
|
453
|
+
# id carries it. A message without an id cannot be grouped and keeps its
|
|
454
|
+
# usage; a frame without usage does not use up its id.
|
|
455
|
+
def generation_usage_attrs(message)
|
|
456
|
+
attrs = compact_attrs(usage_token_attrs(message.usage || {}))
|
|
457
|
+
return attrs if attrs.empty? || message.message_id.nil?
|
|
458
|
+
|
|
459
|
+
@usage_reported_message_ids.add?(message.message_id) ? attrs : {}
|
|
460
|
+
end
|
|
461
|
+
|
|
400
462
|
# Usage hashes arrive symbol-keyed from the live CLI (symbolize_names:
|
|
401
463
|
# true) and string-keyed from session transcripts.
|
|
402
464
|
def usage_value(usage, key)
|