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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +99 -0
  4. data/README.md +43 -31
  5. data/docs/cli-installer.md +26 -4
  6. data/docs/client.md +29 -11
  7. data/docs/configuration.md +164 -1
  8. data/docs/errors.md +32 -2
  9. data/docs/hooks-and-permissions.md +30 -10
  10. data/docs/mcp-servers.md +30 -9
  11. data/docs/observability.md +61 -10
  12. data/docs/options.md +232 -0
  13. data/docs/rails.md +263 -18
  14. data/docs/sessions.md +40 -12
  15. data/docs/subagents.md +1 -1
  16. data/docs/types.md +100 -11
  17. data/lib/claude_agent_sdk/cli_installer.rb +140 -19
  18. data/lib/claude_agent_sdk/command_builder.rb +83 -108
  19. data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
  20. data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
  21. data/lib/claude_agent_sdk/option_forms.rb +379 -0
  22. data/lib/claude_agent_sdk/query/run_lifecycle.rb +373 -0
  23. data/lib/claude_agent_sdk/query.rb +266 -364
  24. data/lib/claude_agent_sdk/railtie.rb +27 -2
  25. data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
  26. data/lib/claude_agent_sdk/session_assembly.rb +311 -0
  27. data/lib/claude_agent_sdk/session_mutations.rb +112 -92
  28. data/lib/claude_agent_sdk/session_resume.rb +360 -43
  29. data/lib/claude_agent_sdk/session_store.rb +31 -2
  30. data/lib/claude_agent_sdk/sessions.rb +720 -138
  31. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +228 -29
  32. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
  33. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
  34. data/lib/claude_agent_sdk/transport.rb +28 -12
  35. data/lib/claude_agent_sdk/types/attributes.rb +9 -0
  36. data/lib/claude_agent_sdk/types/base.rb +85 -15
  37. data/lib/claude_agent_sdk/types/hooks.rb +73 -0
  38. data/lib/claude_agent_sdk/types/mcp.rb +37 -1
  39. data/lib/claude_agent_sdk/types/option_values.rb +186 -4
  40. data/lib/claude_agent_sdk/types/options.rb +35 -5
  41. data/lib/claude_agent_sdk/types/permissions.rb +18 -9
  42. data/lib/claude_agent_sdk/version.rb +1 -1
  43. data/lib/claude_agent_sdk.rb +223 -418
  44. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
  45. data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
  46. data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
  47. data/sig/claude_agent_sdk/types/options.rbs +11 -7
  48. data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
  49. 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
- case @options.system_prompt
73
- when nil
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 String
77
- cmd.push('--system-prompt', @options.system_prompt)
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(@options.system_prompt.prompt))
84
- when SystemPromptPreset
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', @options.system_prompt.append) if @options.system_prompt.append
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
- def append_hash_system_prompt(cmd, prompt_hash)
94
- prompt_type = prompt_hash[:type] || prompt_hash['type']
95
- case prompt_type
96
- when 'file'
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
- cmd.push('--disallowedTools', @options.disallowed_tools.join(',')) unless @options.disallowed_tools.empty?
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.is_a?(String)
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
- if @options.sandbox.nil? # rubocop:disable Metrics/BlockNesting -- settings-is-a-path fallback inside the JSON parse rescue
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
- settings_hash[:sandbox] = @options.sandbox.is_a?(SandboxSettings) ? @options.sandbox.to_h : @options.sandbox
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
- return unless @options.task_budget
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
- type, budget, display = thinking_fields(@options.thinking)
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 budget.nil?
357
+ raise ArgumentError, "thinking type 'enabled' requires budget_tokens" if thinking.budget_tokens.nil?
355
358
 
356
- cmd.push('--max-thinking-tokens', budget.to_s)
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
- case @options.tools
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 = @options.tools.empty? ? '' : @options.tools.join(',')
403
+ tools_value = tools.empty? ? '' : tools.join(',')
410
404
  cmd.push('--tools', tools_value)
411
- when ToolsPreset
412
- cmd.push('--tools', 'default')
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
- if (@options.tools[:type] || @options.tools['type']) == 'preset'
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 = if @options.output_format.is_a?(Hash) && @options.output_format[:type] == 'json_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
- if @options.mcp_servers.is_a?(Hash)
449
- servers_for_cli = {}
450
- @options.mcp_servers.each do |name, config|
451
- # Typed Mcp*ServerConfig objects serialize via their wire hash —
452
- # without this they'd JSON-stringify as "#<...>" via to_s.
453
- config = config.to_h if config.is_a?(Type)
454
- # Same recognition rule as ClaudeAgentSDK.extract_sdk_mcp_servers:
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', @options.mcp_servers.to_s)
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 |plugin|
486
- plugin_config = plugin.is_a?(SdkPluginConfig) ? plugin.to_h : plugin
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?(plugin_type)
491
- raise ArgumentError, "Unsupported plugin type: #{plugin_type.inspect}"
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 plugin_path
468
+ next unless plugin.path
494
469
 
495
- cmd.push('--plugin-dir', plugin_path)
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
- return thread.value if timeout.nil?
339
- raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout)
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
- # Uses standard gen_ai.* semantic conventions recognized by Langfuse, Datadog,
11
- # Jaeger, and other OTel-compatible backends.
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
- # Recording-only by design: a Client session can survive an error (the
103
- # user may rescue one bad message and keep receiving), so finishing here
104
- # would orphan later spans and break the next turn. Finish ownership
105
- # stays with end_trace/on_close; start_trace also finishes any dangling
106
- # span from a previous trace as the never-disconnected backstop.
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
- return unless @root_span
109
-
110
- @root_span.record_exception(error)
111
- @root_span.status = OpenTelemetry::Trace::Status.error(error.message)
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) # rubocop:disable Metrics/AbcSize -- flat mapping of init-message fields to root span attributes
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(usage_token_attrs(message.usage || {}))
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)