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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -0
- data/README.md +551 -37
- data/SPEC.md +70 -30
- data/lib/claude_agent/client.rb +197 -7
- data/lib/claude_agent/content_blocks.rb +193 -5
- data/lib/claude_agent/control_protocol.rb +111 -11
- data/lib/claude_agent/conversation.rb +248 -0
- data/lib/claude_agent/cumulative_usage.rb +106 -0
- data/lib/claude_agent/event_handler.rb +152 -0
- data/lib/claude_agent/hooks.rb +106 -225
- data/lib/claude_agent/list_sessions.rb +508 -0
- data/lib/claude_agent/mcp/server.rb +3 -3
- data/lib/claude_agent/mcp/tool.rb +4 -4
- data/lib/claude_agent/message_parser.rb +201 -185
- data/lib/claude_agent/messages.rb +86 -13
- data/lib/claude_agent/options.rb +5 -4
- data/lib/claude_agent/permission_queue.rb +87 -0
- data/lib/claude_agent/permission_request.rb +151 -0
- data/lib/claude_agent/permissions.rb +4 -2
- data/lib/claude_agent/query.rb +34 -0
- data/lib/claude_agent/tool_activity.rb +78 -0
- data/lib/claude_agent/turn_result.rb +239 -0
- data/lib/claude_agent/types.rb +29 -0
- data/lib/claude_agent/version.rb +1 -1
- data/lib/claude_agent.rb +39 -1
- data/sig/claude_agent.rbs +285 -4
- metadata +9 -1
|
@@ -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) ?
|
|
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) ?
|
|
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) ?
|
|
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) ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|