acp_ruby 0.1.0

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.
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "async"
5
+
6
+ module AgentClientProtocol
7
+ class Connection
8
+ JSONRPC_VERSION = "2.0"
9
+
10
+ attr_reader :reader, :writer
11
+
12
+ def initialize(reader:, writer:, handler:)
13
+ @reader = reader
14
+ @writer = writer
15
+ @handler = handler
16
+ @next_id = 0
17
+ @pending = {} # id -> Async::Promise
18
+ @mutex = Mutex.new
19
+ @closed = false
20
+ end
21
+
22
+ def send_request(method, params = nil)
23
+ id = next_id
24
+ variable = Async::Promise.new
25
+
26
+ @mutex.synchronize { @pending[id] = variable }
27
+
28
+ msg = {"jsonrpc" => JSONRPC_VERSION, "id" => id, "method" => method}
29
+ msg["params"] = params if params
30
+ @writer.write(msg)
31
+
32
+ result = variable.wait
33
+ if result.is_a?(Hash) && result.key?("__error__")
34
+ raise RequestError.from_hash(result["__error__"])
35
+ end
36
+ result
37
+ end
38
+
39
+ def send_notification(method, params = nil)
40
+ msg = {"jsonrpc" => JSONRPC_VERSION, "method" => method}
41
+ msg["params"] = params if params
42
+ @writer.write(msg)
43
+ end
44
+
45
+ def listen
46
+ @reader.each do |message|
47
+ process_message(message)
48
+ end
49
+ rescue IOError, Errno::EPIPE
50
+ # Connection closed
51
+ ensure
52
+ close
53
+ end
54
+
55
+ def close
56
+ return if @closed
57
+
58
+ @closed = true
59
+
60
+ # Reject all pending requests
61
+ @mutex.synchronize do
62
+ @pending.each_value do |var|
63
+ var.resolve({"__error__" => {"code" => -32603, "message" => "Connection closed"}}) unless var.resolved?
64
+ end
65
+ @pending.clear
66
+ end
67
+
68
+ @reader.close
69
+ @writer.close
70
+ end
71
+
72
+ def closed?
73
+ @closed
74
+ end
75
+
76
+ private
77
+
78
+ def next_id
79
+ @mutex.synchronize { @next_id += 1 }
80
+ end
81
+
82
+ def process_message(message)
83
+ has_method = message.key?("method")
84
+ has_id = message.key?("id")
85
+
86
+ if has_method && has_id
87
+ handle_request(message)
88
+ elsif has_method
89
+ handle_notification(message)
90
+ elsif has_id
91
+ handle_response(message)
92
+ end
93
+ end
94
+
95
+ def handle_request(message)
96
+ id = message["id"]
97
+ method = message["method"]
98
+ params = message["params"]
99
+
100
+ begin
101
+ result = @handler.call(method, params, false)
102
+ send_response(id, result: result)
103
+ rescue RequestError => e
104
+ send_response(id, error: e.to_h)
105
+ rescue => e
106
+ send_response(id, error: RequestError.internal_error(e.message).to_h)
107
+ end
108
+ end
109
+
110
+ def handle_notification(message)
111
+ method = message["method"]
112
+ params = message["params"]
113
+
114
+ @handler.call(method, params, true)
115
+ rescue RequestError
116
+ # Notifications don't send error responses
117
+ rescue => e
118
+ # Log but don't propagate — notifications are fire-and-forget
119
+ $stderr.puts("ACP notification handler error: #{e.message}") if $DEBUG
120
+ end
121
+
122
+ def handle_response(message)
123
+ id = message["id"]
124
+ variable = @mutex.synchronize { @pending.delete(id) }
125
+ return unless variable
126
+
127
+ if message.key?("error")
128
+ variable.resolve({"__error__" => message["error"]})
129
+ else
130
+ variable.resolve(message["result"])
131
+ end
132
+ end
133
+
134
+ def send_response(id, result: nil, error: nil)
135
+ msg = {"jsonrpc" => JSONRPC_VERSION, "id" => id}
136
+ if error
137
+ msg["error"] = error
138
+ else
139
+ msg["result"] = result
140
+ end
141
+ @writer.write(msg)
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentClientProtocol
4
+ module Contrib
5
+ class PermissionBroker
6
+ def initialize(requester:, tracker: nil)
7
+ @requester = requester
8
+ @tracker = tracker
9
+ end
10
+
11
+ def request_for(external_id, session_id:, description: nil, options: nil, content: nil, tool_call: nil)
12
+ tc = tool_call
13
+ if tc.nil? && @tracker
14
+ tc = @tracker.tool_call_model(external_id)
15
+ end
16
+ tc ||= Schema::ToolCallUpdate.new(
17
+ tool_call_id: external_id,
18
+ title: description || external_id
19
+ )
20
+
21
+ opts = options || default_permission_options
22
+
23
+ @requester.call(
24
+ session_id: session_id,
25
+ tool_call: tc,
26
+ options: opts
27
+ )
28
+ end
29
+
30
+ def self.default_permission_options
31
+ [
32
+ Schema::PermissionOption.new(
33
+ option_id: "approve",
34
+ name: "Approve",
35
+ kind: Schema::PermissionOptionKind::ALLOW_ONCE
36
+ ),
37
+ Schema::PermissionOption.new(
38
+ option_id: "approve_session",
39
+ name: "Approve for session",
40
+ kind: Schema::PermissionOptionKind::ALLOW_ALWAYS
41
+ ),
42
+ Schema::PermissionOption.new(
43
+ option_id: "reject",
44
+ name: "Reject",
45
+ kind: Schema::PermissionOptionKind::REJECT_ONCE
46
+ )
47
+ ]
48
+ end
49
+
50
+ private
51
+
52
+ def default_permission_options
53
+ self.class.default_permission_options
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentClientProtocol
4
+ module Contrib
5
+ class SessionAccumulator
6
+ SessionSnapshot = Struct.new(
7
+ :session_id, :tool_calls, :plan_entries, :current_mode_id,
8
+ :available_commands, :user_messages, :agent_messages, :agent_thoughts,
9
+ :config_options,
10
+ keyword_init: true
11
+ ) do
12
+ def freeze
13
+ tool_calls&.freeze
14
+ plan_entries&.freeze
15
+ available_commands&.freeze
16
+ user_messages&.freeze
17
+ agent_messages&.freeze
18
+ agent_thoughts&.freeze
19
+ config_options&.freeze
20
+ super
21
+ end
22
+ end
23
+
24
+ def initialize
25
+ reset
26
+ @subscribers = []
27
+ end
28
+
29
+ def reset
30
+ @session_id = nil
31
+ @tool_calls = {}
32
+ @plan_entries = []
33
+ @current_mode_id = nil
34
+ @available_commands = []
35
+ @user_messages = []
36
+ @agent_messages = []
37
+ @agent_thoughts = []
38
+ @config_options = []
39
+ end
40
+
41
+ def apply(notification)
42
+ notif = normalize(notification)
43
+ session_id = notif[:session_id]
44
+ update = notif[:update]
45
+
46
+ # Reset on session change
47
+ if @session_id && @session_id != session_id
48
+ reset
49
+ end
50
+ @session_id = session_id
51
+
52
+ apply_update(update)
53
+
54
+ snap = snapshot
55
+ @subscribers.each { |cb| cb.call(snap, notification) }
56
+ snap
57
+ end
58
+
59
+ def snapshot
60
+ SessionSnapshot.new(
61
+ session_id: @session_id,
62
+ tool_calls: @tool_calls.dup,
63
+ plan_entries: @plan_entries.dup,
64
+ current_mode_id: @current_mode_id,
65
+ available_commands: @available_commands.dup,
66
+ user_messages: @user_messages.dup,
67
+ agent_messages: @agent_messages.dup,
68
+ agent_thoughts: @agent_thoughts.dup,
69
+ config_options: @config_options.dup
70
+ ).freeze
71
+ end
72
+
73
+ def subscribe(&block)
74
+ @subscribers << block
75
+ end
76
+
77
+ private
78
+
79
+ def normalize(notification)
80
+ case notification
81
+ when Schema::SessionNotification
82
+ {session_id: notification.session_id, update: notification.update}
83
+ when Hash
84
+ sid = notification["sessionId"] || notification[:session_id]
85
+ upd = notification["update"] || notification[:update]
86
+ {session_id: sid, update: upd}
87
+ else
88
+ {session_id: notification.session_id, update: notification.update}
89
+ end
90
+ end
91
+
92
+ def apply_update(update)
93
+ return unless update
94
+
95
+ session_update_type = case update
96
+ when TaggedUpdate
97
+ update.tag
98
+ when Hash
99
+ update["sessionUpdate"] || update[:session_update]
100
+ when Schema::BaseModel
101
+ update.to_h["sessionUpdate"]
102
+ end
103
+
104
+ case session_update_type
105
+ when "user_message_chunk"
106
+ content = extract_content(update)
107
+ @user_messages << content if content
108
+ when "agent_message_chunk"
109
+ content = extract_content(update)
110
+ @agent_messages << content if content
111
+ when "agent_thought_chunk"
112
+ content = extract_content(update)
113
+ @agent_thoughts << content if content
114
+ when "tool_call"
115
+ tc_id = extract_field(update, "toolCallId", :tool_call_id)
116
+ @tool_calls[tc_id] = update if tc_id
117
+ when "tool_call_update"
118
+ tc_id = extract_field(update, "toolCallId", :tool_call_id)
119
+ @tool_calls[tc_id] = merge_tool_call(@tool_calls[tc_id], update) if tc_id
120
+ when "plan"
121
+ entries = extract_field(update, "entries", :entries)
122
+ @plan_entries = entries || []
123
+ when "available_commands_update"
124
+ commands = extract_field(update, "availableCommands", :available_commands)
125
+ @available_commands = commands || []
126
+ when "current_mode_update"
127
+ @current_mode_id = extract_field(update, "currentModeId", :current_mode_id)
128
+ when "config_option_update"
129
+ options = extract_field(update, "configOptions", :config_options)
130
+ @config_options = options || []
131
+ end
132
+ end
133
+
134
+ def extract_content(update)
135
+ case update
136
+ when Hash
137
+ update["content"] || update[:content]
138
+ when Schema::ContentChunk
139
+ update.content
140
+ else
141
+ update.respond_to?(:content) ? update.content : nil
142
+ end
143
+ end
144
+
145
+ def extract_field(update, json_key, ruby_key)
146
+ case update
147
+ when Hash
148
+ update[json_key] || update[ruby_key]
149
+ else
150
+ update.respond_to?(ruby_key) ? update.send(ruby_key) : nil
151
+ end
152
+ end
153
+
154
+ def merge_tool_call(existing, update)
155
+ # Just replace with the update — the update contains the latest state
156
+ update
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module AgentClientProtocol
6
+ module Contrib
7
+ class ToolCallTracker
8
+ TrackedToolCallView = Struct.new(
9
+ :external_id, :tool_call_id, :title, :kind, :status,
10
+ :content, :locations, :raw_input, :raw_output, :stream_text,
11
+ keyword_init: true
12
+ )
13
+
14
+ def initialize(id_factory: -> { SecureRandom.hex(16) })
15
+ @id_factory = id_factory
16
+ @tracked = {} # external_id -> TrackedToolCallView
17
+ end
18
+
19
+ def start(external_id, title:, kind: nil, status: nil, content: nil, locations: nil, raw_input: nil, raw_output: nil)
20
+ tool_call_id = @id_factory.call
21
+ view = TrackedToolCallView.new(
22
+ external_id: external_id,
23
+ tool_call_id: tool_call_id,
24
+ title: title,
25
+ kind: kind,
26
+ status: status || Schema::ToolCallStatus::PENDING,
27
+ content: content || [],
28
+ locations: locations || [],
29
+ raw_input: raw_input,
30
+ raw_output: raw_output,
31
+ stream_text: ""
32
+ )
33
+ @tracked[external_id] = view
34
+
35
+ Schema::ToolCall.new(
36
+ tool_call_id: tool_call_id,
37
+ title: title,
38
+ kind: kind,
39
+ status: status || Schema::ToolCallStatus::PENDING,
40
+ content: content,
41
+ locations: locations,
42
+ raw_input: raw_input,
43
+ raw_output: raw_output
44
+ )
45
+ end
46
+
47
+ def progress(external_id, title: nil, kind: nil, status: nil, content: nil, locations: nil, raw_input: nil, raw_output: nil)
48
+ view = @tracked[external_id]
49
+ raise ArgumentError, "Unknown tool call: #{external_id}" unless view
50
+
51
+ view.title = title if title
52
+ view.kind = kind if kind
53
+ view.status = status if status
54
+ view.content = content if content
55
+ view.locations = locations if locations
56
+ view.raw_input = raw_input if raw_input
57
+ view.raw_output = raw_output if raw_output
58
+
59
+ Schema::ToolCallUpdate.new(
60
+ tool_call_id: view.tool_call_id,
61
+ title: title,
62
+ kind: kind,
63
+ status: status,
64
+ content: content,
65
+ locations: locations,
66
+ raw_input: raw_input,
67
+ raw_output: raw_output
68
+ )
69
+ end
70
+
71
+ def append_stream_text(external_id, text, title: nil, status: nil)
72
+ view = @tracked[external_id]
73
+ raise ArgumentError, "Unknown tool call: #{external_id}" unless view
74
+
75
+ view.stream_text = (view.stream_text || "") + text
76
+ view.title = title if title
77
+ view.status = status if status
78
+
79
+ Schema::ToolCallUpdate.new(
80
+ tool_call_id: view.tool_call_id,
81
+ title: title,
82
+ status: status,
83
+ raw_output: view.stream_text
84
+ )
85
+ end
86
+
87
+ def view(external_id)
88
+ view = @tracked[external_id]
89
+ raise ArgumentError, "Unknown tool call: #{external_id}" unless view
90
+
91
+ TrackedToolCallView.new(**view.to_h)
92
+ end
93
+
94
+ def tool_call_model(external_id)
95
+ v = @tracked[external_id]
96
+ raise ArgumentError, "Unknown tool call: #{external_id}" unless v
97
+
98
+ Schema::ToolCallUpdate.new(
99
+ tool_call_id: v.tool_call_id,
100
+ title: v.title,
101
+ kind: v.kind,
102
+ status: v.status,
103
+ content: v.content,
104
+ locations: v.locations,
105
+ raw_input: v.raw_input,
106
+ raw_output: v.raw_output
107
+ )
108
+ end
109
+
110
+ def forget(external_id)
111
+ @tracked.delete(external_id)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentClientProtocol
4
+ class RequestError < StandardError
5
+ attr_reader :code, :data
6
+
7
+ PARSE_ERROR = -32700
8
+ INVALID_REQUEST = -32600
9
+ METHOD_NOT_FOUND = -32601
10
+ INVALID_PARAMS = -32602
11
+ INTERNAL_ERROR = -32603
12
+ AUTH_REQUIRED = -32000
13
+ RESOURCE_NOT_FOUND = -32002
14
+
15
+ def initialize(code, message, data = nil)
16
+ @code = code
17
+ @data = data
18
+ super(message)
19
+ end
20
+
21
+ def to_h
22
+ h = {"code" => @code, "message" => message}
23
+ h["data"] = @data if @data
24
+ h
25
+ end
26
+
27
+ class << self
28
+ def parse_error(data = nil) = new(PARSE_ERROR, "Parse error", data)
29
+ def invalid_request(data = nil) = new(INVALID_REQUEST, "Invalid request", data)
30
+ def method_not_found(method) = new(METHOD_NOT_FOUND, "Method not found", {"method" => method})
31
+ def invalid_params(data = nil) = new(INVALID_PARAMS, "Invalid params", data)
32
+ def internal_error(data = nil) = new(INTERNAL_ERROR, "Internal error", data)
33
+ def auth_required(data = nil) = new(AUTH_REQUIRED, "Authentication required", data)
34
+ def resource_not_found(uri = nil) = new(RESOURCE_NOT_FOUND, "Resource not found", uri ? {"uri" => uri} : nil)
35
+
36
+ def from_hash(hash)
37
+ new(hash["code"], hash["message"], hash["data"])
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentClientProtocol
4
+ module Helpers
5
+ # --- Content blocks ---
6
+
7
+ def text_block(text)
8
+ Schema::TextContent.new(text: text)
9
+ end
10
+
11
+ def image_block(data, mime_type, uri: nil)
12
+ Schema::ImageContent.new(data: data, mime_type: mime_type, uri: uri)
13
+ end
14
+
15
+ def audio_block(data, mime_type)
16
+ Schema::AudioContent.new(data: data, mime_type: mime_type)
17
+ end
18
+
19
+ def resource_link_block(name:, uri:, mime_type: nil, size: nil, description: nil, title: nil)
20
+ Schema::ResourceLink.new(
21
+ name: name, uri: uri, mime_type: mime_type,
22
+ size: size, description: description, title: title
23
+ )
24
+ end
25
+
26
+ def embedded_text_resource(uri:, text:, mime_type: nil)
27
+ Schema::TextResourceContents.new(uri: uri, text: text, mime_type: mime_type)
28
+ end
29
+
30
+ def embedded_blob_resource(uri:, blob:, mime_type: nil)
31
+ Schema::BlobResourceContents.new(uri: uri, blob: blob, mime_type: mime_type)
32
+ end
33
+
34
+ def resource_block(resource)
35
+ Schema::EmbeddedResource.new(resource: resource)
36
+ end
37
+
38
+ # --- Tool call content ---
39
+
40
+ def tool_content(block)
41
+ Schema::Content.new(content: block)
42
+ end
43
+
44
+ def tool_diff_content(path, new_text, old_text: nil)
45
+ Schema::Diff.new(path: path, new_text: new_text, old_text: old_text)
46
+ end
47
+
48
+ def tool_terminal_ref(terminal_id)
49
+ Schema::Terminal.new(terminal_id: terminal_id)
50
+ end
51
+
52
+ # --- Session updates ---
53
+ # These return TaggedUpdate objects that include the sessionUpdate discriminator
54
+ # when serialized, matching the wire format.
55
+
56
+ def update_agent_message(content)
57
+ TaggedUpdate.new("agent_message_chunk", Schema::ContentChunk.new(content: content))
58
+ end
59
+
60
+ def update_agent_message_text(text)
61
+ update_agent_message(text_block(text))
62
+ end
63
+
64
+ def update_user_message(content)
65
+ TaggedUpdate.new("user_message_chunk", Schema::ContentChunk.new(content: content))
66
+ end
67
+
68
+ def update_user_message_text(text)
69
+ update_user_message(text_block(text))
70
+ end
71
+
72
+ def update_agent_thought(content)
73
+ TaggedUpdate.new("agent_thought_chunk", Schema::ContentChunk.new(content: content))
74
+ end
75
+
76
+ def update_agent_thought_text(text)
77
+ update_agent_thought(text_block(text))
78
+ end
79
+
80
+ def update_available_commands(commands)
81
+ TaggedUpdate.new("available_commands_update",
82
+ Schema::AvailableCommandsUpdate.new(available_commands: commands))
83
+ end
84
+
85
+ def update_current_mode(current_mode_id)
86
+ TaggedUpdate.new("current_mode_update",
87
+ Schema::CurrentModeUpdate.new(current_mode_id: current_mode_id))
88
+ end
89
+
90
+ def update_config_options(config_options)
91
+ TaggedUpdate.new("config_option_update",
92
+ Schema::ConfigOptionUpdate.new(config_options: config_options))
93
+ end
94
+
95
+ # --- Plans ---
96
+
97
+ def plan_entry(content, priority: Schema::PlanEntryPriority::MEDIUM, status: Schema::PlanEntryStatus::PENDING)
98
+ Schema::PlanEntry.new(content: content, priority: priority, status: status)
99
+ end
100
+
101
+ def update_plan(entries)
102
+ TaggedUpdate.new("plan", Schema::Plan.new(entries: entries))
103
+ end
104
+
105
+ # --- Tool calls ---
106
+
107
+ def start_tool_call(tool_call_id, title, kind: nil, status: nil, content: nil, locations: nil, raw_input: nil, raw_output: nil)
108
+ TaggedUpdate.new("tool_call", Schema::ToolCall.new(
109
+ tool_call_id: tool_call_id,
110
+ title: title,
111
+ kind: kind,
112
+ status: status,
113
+ content: content,
114
+ locations: locations,
115
+ raw_input: raw_input,
116
+ raw_output: raw_output
117
+ ))
118
+ end
119
+
120
+ def update_tool_call(tool_call_id, title: nil, kind: nil, status: nil, content: nil, locations: nil, raw_input: nil, raw_output: nil)
121
+ TaggedUpdate.new("tool_call_update", Schema::ToolCallUpdate.new(
122
+ tool_call_id: tool_call_id,
123
+ title: title,
124
+ kind: kind,
125
+ status: status,
126
+ content: content,
127
+ locations: locations,
128
+ raw_input: raw_input,
129
+ raw_output: raw_output
130
+ ))
131
+ end
132
+
133
+ # --- Notification wrapper ---
134
+
135
+ def session_notification(session_id, update)
136
+ Schema::SessionNotification.new(session_id: session_id, update: update)
137
+ end
138
+ end
139
+
140
+ # A session update tagged with its discriminator value.
141
+ # Serializes to include the "sessionUpdate" key in the wire format.
142
+ class TaggedUpdate
143
+ attr_reader :tag, :model
144
+
145
+ def initialize(tag, model)
146
+ @tag = tag
147
+ @model = model
148
+ end
149
+
150
+ def to_h
151
+ h = @model.is_a?(Schema::BaseModel) ? @model.to_h : @model
152
+ {"sessionUpdate" => @tag}.merge(h)
153
+ end
154
+
155
+ def to_json(*)
156
+ JSON.generate(to_h, *)
157
+ end
158
+
159
+ # Delegate attribute access to the underlying model
160
+ def respond_to_missing?(method, include_private = false)
161
+ @model.respond_to?(method, include_private) || super
162
+ end
163
+
164
+ def method_missing(method, ...)
165
+ if @model.respond_to?(method)
166
+ @model.send(method, ...)
167
+ else
168
+ super
169
+ end
170
+ end
171
+
172
+ def inspect
173
+ "#<TaggedUpdate tag=#{@tag.inspect} #{@model.inspect}>"
174
+ end
175
+ end
176
+ end