claude_agent 0.7.7 → 0.7.9

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.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
4
+
3
5
  module ClaudeAgent
4
6
  # Text content block
5
7
  #
@@ -37,6 +39,7 @@ module ClaudeAgent
37
39
  #
38
40
  # @example
39
41
  # block = ToolUseBlock.new(id: "tool_123", name: "Read", input: {file_path: "/tmp/file"})
42
+ # block.input[:file_path] # => "/tmp/file"
40
43
  # block.name # => "Read"
41
44
  #
42
45
  ToolUseBlock = Data.define(:id, :name, :input) do
@@ -47,6 +50,118 @@ module ClaudeAgent
47
50
  def to_h
48
51
  { type: "tool_use", id: id, name: name, input: input }
49
52
  end
53
+
54
+ # Returns the file path for file-based tools, nil otherwise.
55
+ # @return [String, nil]
56
+ def file_path
57
+ case name
58
+ when "Read", "Write", "Edit"
59
+ input[:file_path]
60
+ when "NotebookEdit"
61
+ input[:notebook_path]
62
+ end
63
+ end
64
+
65
+ # One-line human-readable label for the tool call.
66
+ # @return [String]
67
+ def display_label
68
+ case name
69
+ when "Read", "Write", "Edit", "NotebookEdit"
70
+ path = file_path
71
+ path ? "#{name} #{shorten_path(path)}" : name
72
+ when "Bash"
73
+ cmd = input[:command]
74
+ cmd ? "Bash: #{truncate(cmd, 50)}" : "Bash"
75
+ when "Grep"
76
+ pattern = input[:pattern]
77
+ pattern ? "Grep: #{pattern}" : "Grep"
78
+ when "Glob"
79
+ pattern = input[:pattern]
80
+ pattern ? "Glob: #{pattern}" : "Glob"
81
+ when "WebFetch"
82
+ host = extract_host(input[:url])
83
+ host ? "WebFetch: #{host}" : "WebFetch"
84
+ when "WebSearch"
85
+ query = input[:query]
86
+ query ? "WebSearch: #{truncate(query, 50)}" : "WebSearch"
87
+ when "Task"
88
+ desc = input[:description]
89
+ desc ? "Task: #{truncate(desc, 50)}" : "Task"
90
+ else
91
+ name
92
+ end
93
+ end
94
+
95
+ # Detailed summary of the tool call, truncated to max chars.
96
+ # @param max [Integer] maximum length before truncation
97
+ # @return [String]
98
+ def summary(max: 60)
99
+ text = case name
100
+ when "Read"
101
+ path = file_path
102
+ path ? "Read: #{path}" : "Read"
103
+ when "Write"
104
+ path = file_path
105
+ if path
106
+ size = content_size(input[:content])
107
+ "Write: #{path} (#{size})"
108
+ else
109
+ "Write"
110
+ end
111
+ when "Edit"
112
+ path = file_path
113
+ if path
114
+ old = input[:old_string]
115
+ lines = old ? old.count("\n") + 1 : 0
116
+ "Edit: #{path} replacing #{lines} line(s)"
117
+ else
118
+ "Edit"
119
+ end
120
+ when "Bash"
121
+ cmd = input[:command]
122
+ cmd ? "Bash: #{cmd}" : "Bash"
123
+ when "Grep"
124
+ pattern = input[:pattern]
125
+ path = input[:path]
126
+ glob = input[:glob]
127
+ parts = [ "Grep: #{pattern}" ]
128
+ parts << "in #{path}" if path
129
+ parts << "(#{glob})" if glob
130
+ parts.join(" ")
131
+ when "NotebookEdit"
132
+ path = file_path
133
+ path ? "NotebookEdit: #{path}" : "NotebookEdit"
134
+ else
135
+ "#{name}: #{input.inspect}"
136
+ end
137
+
138
+ truncate(text, max)
139
+ end
140
+
141
+ private
142
+
143
+ def shorten_path(path)
144
+ parts = path.to_s.split("/")
145
+ parts.length > 2 ? parts.last(2).join("/") : path.to_s
146
+ end
147
+
148
+ def truncate(str, max)
149
+ return str if str.length <= max
150
+ "#{str[0, max]}..."
151
+ end
152
+
153
+ def extract_host(url)
154
+ return nil if url.nil?
155
+ URI.parse(url.to_s).host
156
+ rescue URI::InvalidURIError
157
+ nil
158
+ end
159
+
160
+ def content_size(content)
161
+ return "empty" if content.nil? || content.empty?
162
+ lines = content.count("\n") + 1
163
+ lines == 1 ? "1 line" : "#{lines} lines"
164
+ end
50
165
  end
51
166
 
52
167
  # Tool result block
@@ -81,6 +196,39 @@ module ClaudeAgent
81
196
  def to_h
82
197
  { type: "server_tool_use", id: id, name: name, input: input, server_name: server_name }
83
198
  end
199
+
200
+ # Returns the file path for file-based tools, nil otherwise.
201
+ # @return [String, nil]
202
+ def file_path
203
+ case name
204
+ when "Read", "Write", "Edit"
205
+ input[:file_path]
206
+ when "NotebookEdit"
207
+ input[:notebook_path]
208
+ end
209
+ end
210
+
211
+ # One-line human-readable label with server context.
212
+ # @return [String]
213
+ def display_label
214
+ server_name ? "#{server_name}/#{name}" : name
215
+ end
216
+
217
+ # Detailed summary with server context, truncated to max chars.
218
+ # @param max [Integer] maximum length before truncation
219
+ # @return [String]
220
+ def summary(max: 60)
221
+ label = display_label
222
+ text = "#{label}: #{input.inspect}"
223
+ truncate(text, max)
224
+ end
225
+
226
+ private
227
+
228
+ def truncate(str, max)
229
+ return str if str.length <= max
230
+ "#{str[0, max]}..."
231
+ end
84
232
  end
85
233
 
86
234
  # Server tool result block
@@ -110,11 +258,14 @@ module ClaudeAgent
110
258
  # block = ImageContentBlock.new(
111
259
  # source: { type: "base64", media_type: "image/png", data: "..." }
112
260
  # )
261
+ # block.source_type # => "base64"
262
+ # block.media_type # => "image/png"
113
263
  #
114
264
  # @example URL image
115
265
  # block = ImageContentBlock.new(
116
266
  # source: { type: "url", url: "https://example.com/image.png" }
117
267
  # )
268
+ # block.url # => "https://example.com/image.png"
118
269
  #
119
270
  ImageContentBlock = Data.define(:source) do
120
271
  def type
@@ -124,25 +275,25 @@ module ClaudeAgent
124
275
  # Get the media type if available
125
276
  # @return [String, nil]
126
277
  def media_type
127
- source.is_a?(Hash) ? (source[:media_type] || source["media_type"]) : nil
278
+ source.is_a?(Hash) ? source[:media_type] : nil
128
279
  end
129
280
 
130
281
  # Get the base64 data if available
131
282
  # @return [String, nil]
132
283
  def data
133
- source.is_a?(Hash) ? (source[:data] || source["data"]) : nil
284
+ source.is_a?(Hash) ? source[:data] : nil
134
285
  end
135
286
 
136
287
  # Get the URL if this is a URL-sourced image
137
288
  # @return [String, nil]
138
289
  def url
139
- source.is_a?(Hash) ? (source[:url] || source["url"]) : nil
290
+ source.is_a?(Hash) ? source[:url] : nil
140
291
  end
141
292
 
142
293
  # Get the source type (base64 or url)
143
294
  # @return [String, nil]
144
295
  def source_type
145
- source.is_a?(Hash) ? (source[:type] || source["type"]) : nil
296
+ source.is_a?(Hash) ? source[:type] : nil
146
297
  end
147
298
 
148
299
  def to_h
@@ -150,6 +301,42 @@ module ClaudeAgent
150
301
  end
151
302
  end
152
303
 
304
+ # Generic content block for unknown/future block types
305
+ #
306
+ # Wraps unrecognized content block types so they can be inspected
307
+ # without losing type information. Supports dynamic field access via
308
+ # `[]` and `method_missing`.
309
+ #
310
+ # @example
311
+ # block = GenericBlock.new(block_type: "citation", raw: { text: "ref", url: "https://example.com" })
312
+ # block.type # => :citation
313
+ # block[:text] # => "ref"
314
+ # block.url # => "https://example.com"
315
+ # block.to_h # => { text: "ref", url: "https://example.com" }
316
+ #
317
+ GenericBlock = Data.define(:block_type, :raw) do
318
+ def type
319
+ block_type&.to_sym || :unknown
320
+ end
321
+
322
+ def to_h
323
+ raw
324
+ end
325
+
326
+ def [](key)
327
+ raw[key]
328
+ end
329
+
330
+ def respond_to_missing?(name, include_private = false)
331
+ raw.key?(name) || super
332
+ end
333
+
334
+ def method_missing(name, *args)
335
+ return raw[name] if args.empty? && raw.key?(name)
336
+ super
337
+ end
338
+ end
339
+
153
340
  # All content block types
154
341
  CONTENT_BLOCK_TYPES = [
155
342
  TextBlock,
@@ -158,6 +345,7 @@ module ClaudeAgent
158
345
  ToolResultBlock,
159
346
  ServerToolUseBlock,
160
347
  ServerToolResultBlock,
161
- ImageContentBlock
348
+ ImageContentBlock,
349
+ GenericBlock
162
350
  ].freeze
163
351
  end
@@ -24,6 +24,7 @@ module ClaudeAgent
24
24
  REQUEST_ID_PREFIX = "req"
25
25
 
26
26
  attr_reader :transport, :options, :server_info
27
+ attr_accessor :permission_queue
27
28
 
28
29
  # @param transport [Transport::Base] Transport for communication
29
30
  # @param options [Options] Configuration options
@@ -95,6 +96,9 @@ module ClaudeAgent
95
96
  def abort!
96
97
  @running = false
97
98
 
99
+ # Drain permission queue so reader thread unblocks
100
+ @permission_queue&.drain!(reason: "Operation aborted")
101
+
98
102
  # Fail all pending requests
99
103
  @mutex.synchronize do
100
104
  @pending_requests.each_key do |request_id|
@@ -443,6 +447,30 @@ module ClaudeAgent
443
447
  send_control_request(subtype: "mcp_toggle", serverName: server_name, enabled: enabled)
444
448
  end
445
449
 
450
+ # Initiate OAuth authentication for an MCP server (TypeScript SDK v0.2.52 parity)
451
+ #
452
+ # @param server_name [String] Name of the MCP server to authenticate
453
+ # @return [Hash] Response from the CLI
454
+ #
455
+ # @example
456
+ # protocol.mcp_authenticate("my-remote-server")
457
+ #
458
+ def mcp_authenticate(server_name)
459
+ send_control_request(subtype: "mcp_authenticate", serverName: server_name)
460
+ end
461
+
462
+ # Clear stored auth credentials for an MCP server (TypeScript SDK v0.2.52 parity)
463
+ #
464
+ # @param server_name [String] Name of the MCP server to clear auth for
465
+ # @return [Hash] Response from the CLI
466
+ #
467
+ # @example
468
+ # protocol.mcp_clear_auth("my-remote-server")
469
+ #
470
+ def mcp_clear_auth(server_name)
471
+ send_control_request(subtype: "mcp_clear_auth", serverName: server_name)
472
+ end
473
+
446
474
  # Stop a running background task (TypeScript SDK parity)
447
475
  #
448
476
  # Sends a stop signal to a running task. A task_notification message
@@ -458,6 +486,20 @@ module ClaudeAgent
458
486
  send_control_request(subtype: "stop_task", task_id: task_id)
459
487
  end
460
488
 
489
+ # Apply flag settings (TypeScript SDK v0.2.50 parity)
490
+ #
491
+ # Merges the provided settings into the flag settings layer.
492
+ #
493
+ # @param settings [Hash] Settings to merge into the flag layer
494
+ # @return [Hash] Response from the CLI
495
+ #
496
+ # @example
497
+ # protocol.apply_flag_settings({ "model" => "claude-sonnet-4-5-20250514" })
498
+ #
499
+ def apply_flag_settings(settings)
500
+ send_control_request(subtype: "apply_flag_settings", settings: settings)
501
+ end
502
+
461
503
  # Dynamically set MCP servers for this session (TypeScript SDK parity)
462
504
  #
463
505
  # This replaces the current set of dynamically-added MCP servers.
@@ -588,27 +630,85 @@ module ClaudeAgent
588
630
  end
589
631
 
590
632
  # Handle can_use_tool permission request
633
+ #
634
+ # Supports three modes:
635
+ # 1. Synchronous callback — can_use_tool is set, returns result directly
636
+ # 2. Queue-based — permission_queue is set, enqueues and waits for resolution
637
+ # 3. Default allow — neither is set
638
+ #
639
+ # In hybrid mode (callback + queue), the callback can call
640
+ # context.request.defer! to enqueue the request instead of
641
+ # returning a synchronous answer.
642
+ #
591
643
  # @param request [Hash] Request data
592
644
  # @return [Hash] Response
593
645
  def handle_can_use_tool(request)
594
- unless options.can_use_tool
595
- logger.info("protocol") { "Permission decision for #{request["tool_name"]}: allow (no callback)" }
596
- return { behavior: "allow" }
597
- end
598
-
599
646
  tool_name = request["tool_name"]
600
- input = request["input"] || {}
601
- context = {
647
+ input = (request["input"] || {}).deep_symbolize_keys
648
+
649
+ # Build PermissionRequest for queue/hybrid modes
650
+ perm_request = PermissionRequest.new(
651
+ tool_name: tool_name,
652
+ input: input,
653
+ context: nil, # set below after ToolPermissionContext is built
654
+ request_id: request["tool_use_id"] || SecureRandom.hex(8)
655
+ )
656
+
657
+ context = ToolPermissionContext.new(
602
658
  permission_suggestions: request["permission_suggestions"],
603
659
  blocked_path: request["blocked_path"],
604
660
  decision_reason: request["decision_reason"],
605
661
  tool_use_id: request["tool_use_id"],
606
662
  agent_id: request["agent_id"],
607
- description: request["description"]
608
- }
663
+ description: request["description"],
664
+ signal: @abort_signal,
665
+ request: perm_request
666
+ )
609
667
 
610
- result = options.can_use_tool.call(tool_name, input, context)
668
+ # Back-fill context on the request (circular, but both are needed)
669
+ perm_request.instance_variable_set(:@context, context)
670
+
671
+ # Mode 1: Synchronous callback
672
+ if options.can_use_tool
673
+ result = options.can_use_tool.call(tool_name, input, context)
674
+
675
+ # Check if the callback deferred to the queue
676
+ if perm_request.deferred?
677
+ return enqueue_and_wait(perm_request, tool_name, input)
678
+ end
611
679
 
680
+ return normalize_permission_result(result, tool_name, input)
681
+ end
682
+
683
+ # Mode 2: Queue-based
684
+ if @permission_queue
685
+ return enqueue_and_wait(perm_request, tool_name, input)
686
+ end
687
+
688
+ # Mode 3: Default allow
689
+ logger.info("protocol") { "Permission decision for #{tool_name}: allow (no callback)" }
690
+ { behavior: "allow" }
691
+ end
692
+
693
+ # Enqueue a permission request and block until resolved
694
+ # @param perm_request [PermissionRequest] The request to enqueue
695
+ # @param tool_name [String] Tool name (for logging)
696
+ # @param input [Hash] Original tool input
697
+ # @return [Hash] Normalized response
698
+ def enqueue_and_wait(perm_request, tool_name, input)
699
+ logger.info("protocol") { "Permission request queued for #{tool_name}" }
700
+ @permission_queue.push(perm_request)
701
+
702
+ result = perm_request.wait(timeout: DEFAULT_TIMEOUT)
703
+ normalize_permission_result(result, tool_name, input)
704
+ end
705
+
706
+ # Normalize a permission result for the CLI response
707
+ # @param result [PermissionResultAllow, PermissionResultDeny, Hash] The result
708
+ # @param tool_name [String] Tool name (for logging)
709
+ # @param input [Hash] Original tool input
710
+ # @return [Hash] Normalized response
711
+ def normalize_permission_result(result, tool_name, input)
612
712
  normalized = result.to_h
613
713
  logger.info("protocol") { "Permission decision for #{tool_name}: #{normalized[:behavior]}" }
614
714
 
@@ -624,7 +724,7 @@ module ClaudeAgent
624
724
  # @return [Hash] Response
625
725
  def handle_hook_callback(request)
626
726
  callback_id = request["callback_id"]
627
- input = request["input"] || {}
727
+ input = (request["input"] || {}).deep_symbolize_keys
628
728
  tool_use_id = request["tool_use_id"]
629
729
 
630
730
  callback = @hook_callbacks[callback_id]
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # High-level conversation interface managing the full lifecycle.
5
+ #
6
+ # Wraps {Client} and composes {TurnResult}, {EventHandler},
7
+ # {CumulativeUsage}, and {PermissionQueue} into a single stateful
8
+ # object. Auto-connects on first {#say}, tracks multi-turn history,
9
+ # and builds a unified tool activity timeline.
10
+ #
11
+ # @example Basic
12
+ # conversation = ClaudeAgent::Conversation.new(
13
+ # model: "claude-sonnet-4-5-20250514",
14
+ # on_stream: ->(text) { print text }
15
+ # )
16
+ # turn = conversation.say("Fix the bug in auth.rb")
17
+ # puts turn.text
18
+ # conversation.close
19
+ #
20
+ # @example Block form
21
+ # ClaudeAgent::Conversation.open(permission_mode: "default") do |c|
22
+ # c.say("Help me write a function")
23
+ # c.say("Now add tests")
24
+ # puts "Total cost: $#{c.total_cost}"
25
+ # end
26
+ #
27
+ # @example Resume a previous session
28
+ # conversation = ClaudeAgent::Conversation.resume("session-abc")
29
+ # conversation.say("Continue where we left off")
30
+ #
31
+ class Conversation
32
+ # Keys consumed by Conversation; everything else forwards to Options
33
+ CONVERSATION_KEYS = %i[
34
+ on_text on_stream on_tool_use on_tool_result
35
+ on_thinking on_result on_message on_permission
36
+ client options
37
+ ].freeze
38
+
39
+ attr_reader :turns, :messages, :tool_activity, :client
40
+
41
+ # Open a conversation with automatic cleanup.
42
+ #
43
+ # @yield [Conversation] The conversation
44
+ # @return [Object] Result of block
45
+ def self.open(**kwargs)
46
+ conversation = new(**kwargs)
47
+ begin
48
+ yield conversation
49
+ ensure
50
+ conversation.close
51
+ end
52
+ end
53
+
54
+ # Resume a previous conversation by session ID.
55
+ #
56
+ # @param session_id [String] Session ID to resume
57
+ # @return [Conversation]
58
+ def self.resume(session_id, **kwargs)
59
+ new(resume: session_id, **kwargs)
60
+ end
61
+
62
+ # Create a new conversation.
63
+ #
64
+ # Accepts all {Options} keyword arguments plus conversation-level
65
+ # callbacks:
66
+ #
67
+ # @param on_text [Proc] Handler for streaming text
68
+ # @param on_stream [Proc] Alias for on_text
69
+ # @param on_tool_use [Proc] Handler for tool use events
70
+ # @param on_tool_result [Proc] Handler for tool result events
71
+ # @param on_thinking [Proc] Handler for thinking events
72
+ # @param on_result [Proc] Handler for result events
73
+ # @param on_message [Proc] Handler for all messages
74
+ # @param on_permission [Symbol, Proc] :queue (default) or a callable for can_use_tool
75
+ # @param client [Client] Pre-built client (for testing)
76
+ # @param options [Options] Pre-built options object
77
+ #
78
+ def initialize(**kwargs)
79
+ conversation_kwargs = kwargs.slice(*CONVERSATION_KEYS)
80
+ options_kwargs = kwargs.except(*CONVERSATION_KEYS)
81
+
82
+ @options = conversation_kwargs[:options] || build_options(options_kwargs, conversation_kwargs)
83
+ @client = conversation_kwargs[:client] || Client.new(options: @options)
84
+
85
+ @turns = []
86
+ @messages = []
87
+ @tool_activity = []
88
+ @tool_use_timestamps = {}
89
+ @tool_result_timestamps = {}
90
+ @connected = false
91
+ @closed = false
92
+
93
+ register_callbacks(conversation_kwargs)
94
+ register_timing_hooks
95
+ end
96
+
97
+ # Send a message and receive the complete turn result.
98
+ #
99
+ # Auto-connects on first call. Appends to conversation history.
100
+ #
101
+ # @param prompt [String, Array] The message content
102
+ # @yield [Message] Each message as it streams in (optional)
103
+ # @return [TurnResult] The completed turn
104
+ def say(prompt, &block)
105
+ ensure_connected!
106
+
107
+ logger.debug("conversation") { "Turn #{@turns.size}: sending message" }
108
+
109
+ turn = @client.send_and_receive(prompt) do |message|
110
+ @messages << message
111
+ block&.call(message)
112
+ end
113
+
114
+ @turns << turn
115
+ build_tool_activities(turn, @turns.size - 1)
116
+
117
+ logger.info("conversation") { "Turn #{@turns.size - 1} complete (#{turn.tool_uses.size} tools, cost=$#{total_cost})" }
118
+
119
+ turn
120
+ end
121
+
122
+ # Total cost across all turns.
123
+ # @return [Float]
124
+ def total_cost
125
+ @client.cumulative_usage.total_cost_usd
126
+ end
127
+
128
+ # Session ID from the most recent turn.
129
+ # @return [String, nil]
130
+ def session_id
131
+ @turns.last&.session_id
132
+ end
133
+
134
+ # Cumulative usage stats.
135
+ # @return [CumulativeUsage]
136
+ def usage
137
+ @client.cumulative_usage
138
+ end
139
+
140
+ # Non-blocking poll for the next pending permission request.
141
+ # @return [PermissionRequest, nil]
142
+ def pending_permission
143
+ @client.pending_permission
144
+ end
145
+
146
+ # Whether any permission requests are pending.
147
+ # @return [Boolean]
148
+ def pending_permissions?
149
+ @client.pending_permissions?
150
+ end
151
+
152
+ # Close the conversation and disconnect the client.
153
+ # @return [void]
154
+ def close
155
+ return if @closed
156
+ logger.info("conversation") { "Closing (#{@turns.size} turns, cost=$#{total_cost})" }
157
+ @client.disconnect if @connected
158
+ @connected = false
159
+ @closed = true
160
+ end
161
+
162
+ # Whether the conversation is open (client connected).
163
+ # @return [Boolean]
164
+ def open?
165
+ @connected && !@closed
166
+ end
167
+
168
+ # Whether the conversation has been closed.
169
+ # @return [Boolean]
170
+ def closed?
171
+ @closed
172
+ end
173
+
174
+ def inspect
175
+ parts = [ "#<#{self.class}" ]
176
+ parts << "turns=#{@turns.size}"
177
+ parts << "messages=#{@messages.size}"
178
+ parts << "tools=#{@tool_activity.size}" unless @tool_activity.empty?
179
+ parts << "cost=$#{total_cost}" if total_cost > 0
180
+ parts << "session=#{session_id}" if session_id
181
+ parts << (
182
+ if closed?
183
+ "closed"
184
+ else
185
+ open? ? "open" : "pending"
186
+ end)
187
+ "#{parts.join(" ")}>"
188
+ end
189
+
190
+ private
191
+
192
+ def build_options(options_kwargs, conversation_kwargs)
193
+ permission = conversation_kwargs[:on_permission]
194
+
195
+ if permission.respond_to?(:call)
196
+ options_kwargs[:can_use_tool] = permission
197
+ elsif permission == :queue || permission.nil?
198
+ options_kwargs[:permission_queue] = true unless options_kwargs.key?(:can_use_tool)
199
+ end
200
+
201
+ Options.new(**options_kwargs)
202
+ end
203
+
204
+ def register_callbacks(kwargs)
205
+ mapping = {
206
+ on_text: :text, on_stream: :text, on_thinking: :thinking,
207
+ on_tool_use: :tool_use, on_tool_result: :tool_result,
208
+ on_result: :result, on_message: :message
209
+ }
210
+
211
+ mapping.each do |key, event|
212
+ callback = kwargs[key]
213
+ @client.on(event, &callback) if callback
214
+ end
215
+ end
216
+
217
+ def register_timing_hooks
218
+ @client.on(:tool_use) { |tool| @tool_use_timestamps[tool.id] = Time.now }
219
+ @client.on(:tool_result) { |result, _| @tool_result_timestamps[result.tool_use_id] = Time.now }
220
+ end
221
+
222
+ def build_tool_activities(turn, turn_index)
223
+ turn.tool_executions.each do |exec|
224
+ tool_id = exec[:tool_use].id
225
+ @tool_activity << ToolActivity.new(
226
+ tool_use: exec[:tool_use],
227
+ tool_result: exec[:tool_result],
228
+ turn_index: turn_index,
229
+ started_at: @tool_use_timestamps.delete(tool_id),
230
+ completed_at: @tool_result_timestamps.delete(tool_id)
231
+ )
232
+ end
233
+ end
234
+
235
+ def logger
236
+ @options.effective_logger
237
+ end
238
+
239
+ def ensure_connected!
240
+ raise Error, "Conversation is closed" if @closed
241
+ return if @connected
242
+
243
+ logger.info("conversation") { "Auto-connecting" }
244
+ @client.connect
245
+ @connected = true
246
+ end
247
+ end
248
+ end