acp_sdk_async 0.2.0 → 0.3.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,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../schema"
4
+ require_relative "tool_calls"
5
+
6
+ module ACP
7
+ module Contrib
8
+ class SessionNotificationMismatchError < ArgumentError
9
+ def initialize(expected, actual)
10
+ super("SessionAccumulator received notification for #{actual}, expected #{expected}")
11
+ end
12
+ end
13
+
14
+ class SessionSnapshotUnavailableError < RuntimeError
15
+ def initialize
16
+ super("SessionAccumulator has not processed any notifications yet")
17
+ end
18
+ end
19
+
20
+ # Immutable view of a tool call in the session.
21
+ class ToolCallView
22
+ attr_reader :tool_call_id, :title, :kind, :status,
23
+ :content, :locations, :raw_input, :raw_output
24
+
25
+ def initialize(tool_call_id:, title:, kind:, status:,
26
+ content:, locations:, raw_input:, raw_output:)
27
+ @tool_call_id = tool_call_id
28
+ @title = title
29
+ @kind = kind
30
+ @status = status
31
+ @content = content&.freeze
32
+ @locations = locations&.freeze
33
+ @raw_input = raw_input
34
+ @raw_output = raw_output
35
+ freeze
36
+ end
37
+
38
+ def ==(other)
39
+ other.instance_of?(self.class) && to_h == other.to_h
40
+ end
41
+
42
+ alias eql? ==
43
+
44
+ def hash
45
+ [self.class, to_h].hash
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ tool_call_id: @tool_call_id, title: @title, kind: @kind,
51
+ status: @status, content: @content, locations: @locations,
52
+ raw_input: @raw_input, raw_output: @raw_output
53
+ }
54
+ end
55
+ end
56
+
57
+ # Aggregated immutable snapshot of the most recent session state.
58
+ class SessionSnapshot
59
+ attr_reader :session_id, :tool_calls, :plan_entries, :current_mode_id,
60
+ :available_commands, :user_messages, :agent_messages, :agent_thoughts
61
+
62
+ def initialize(session_id:, tool_calls:, plan_entries:, current_mode_id:,
63
+ available_commands:, user_messages:, agent_messages:, agent_thoughts:)
64
+ @session_id = session_id
65
+ @tool_calls = tool_calls.freeze
66
+ @plan_entries = plan_entries.freeze
67
+ @current_mode_id = current_mode_id
68
+ @available_commands = available_commands.freeze
69
+ @user_messages = user_messages.freeze
70
+ @agent_messages = agent_messages.freeze
71
+ @agent_thoughts = agent_thoughts.freeze
72
+ freeze
73
+ end
74
+ end
75
+
76
+ class MutableToolCallState
77
+ attr_reader :tool_call_id
78
+
79
+ def initialize(tool_call_id)
80
+ @tool_call_id = tool_call_id
81
+ @title = nil
82
+ @kind = nil
83
+ @status = nil
84
+ @content = nil
85
+ @locations = nil
86
+ @raw_input = nil
87
+ @raw_output = nil
88
+ end
89
+
90
+ def apply_start(update)
91
+ @title = update.title
92
+ @kind = update.kind
93
+ @status = update.status
94
+ @content = Contrib.copy_model_list(update.content)
95
+ @locations = Contrib.copy_model_list(update.locations)
96
+ @raw_input = update.raw_input
97
+ @raw_output = update.raw_output
98
+ end
99
+
100
+ def apply_progress(update)
101
+ @title = update.title unless update.title.nil?
102
+ @kind = update.kind unless update.kind.nil?
103
+ @status = update.status unless update.status.nil?
104
+ @content = Contrib.copy_model_list(update.content) unless update.content.nil?
105
+ @locations = Contrib.copy_model_list(update.locations) unless update.locations.nil?
106
+ @raw_input = update.raw_input unless update.raw_input.nil?
107
+ @raw_output = update.raw_output unless update.raw_output.nil?
108
+ end
109
+
110
+ def snapshot
111
+ ToolCallView.new(
112
+ tool_call_id: @tool_call_id,
113
+ title: @title,
114
+ kind: @kind,
115
+ status: @status,
116
+ content: @content&.map { |item| Contrib.deep_copy_model(item) },
117
+ locations: @locations&.map { |loc| Contrib.deep_copy_model(loc) },
118
+ raw_input: @raw_input,
119
+ raw_output: @raw_output
120
+ )
121
+ end
122
+ end
123
+
124
+ # Merges SessionNotification objects into a session snapshot.
125
+ # Experimental: APIs may change while feedback is gathered.
126
+ class SessionAccumulator
127
+ attr_reader :session_id
128
+
129
+ def initialize(auto_reset_on_session_change: true)
130
+ @auto_reset = auto_reset_on_session_change
131
+ @session_id = nil
132
+ @tool_calls = {}
133
+ @plan_entries = []
134
+ @current_mode_id = nil
135
+ @available_commands = []
136
+ @user_messages = []
137
+ @agent_messages = []
138
+ @agent_thoughts = []
139
+ @subscribers = []
140
+ end
141
+
142
+ def reset
143
+ @session_id = nil
144
+ @tool_calls.clear
145
+ @plan_entries.clear
146
+ @current_mode_id = nil
147
+ @available_commands.clear
148
+ @user_messages.clear
149
+ @agent_messages.clear
150
+ @agent_thoughts.clear
151
+ end
152
+
153
+ # Registers a callback invoked with (snapshot, notification) after
154
+ # every apply. Returns an unsubscribe lambda.
155
+ def subscribe(&callback)
156
+ @subscribers << callback
157
+ -> { @subscribers.delete(callback) }
158
+ end
159
+
160
+ def apply(notification)
161
+ ensure_session(notification)
162
+ apply_update(notification.update)
163
+ snap = snapshot
164
+ @subscribers.dup.each { |callback| callback.call(snap, notification) }
165
+ snap
166
+ end
167
+
168
+ def snapshot
169
+ raise SessionSnapshotUnavailableError if @session_id.nil?
170
+
171
+ SessionSnapshot.new(
172
+ session_id: @session_id,
173
+ tool_calls: @tool_calls.transform_values(&:snapshot),
174
+ plan_entries: @plan_entries.map { |entry| Contrib.deep_copy_model(entry) },
175
+ current_mode_id: @current_mode_id,
176
+ available_commands: @available_commands.map { |command| Contrib.deep_copy_model(command) },
177
+ user_messages: @user_messages.map { |message| Contrib.deep_copy_model(message) },
178
+ agent_messages: @agent_messages.map { |message| Contrib.deep_copy_model(message) },
179
+ agent_thoughts: @agent_thoughts.map { |message| Contrib.deep_copy_model(message) }
180
+ )
181
+ end
182
+
183
+ private
184
+
185
+ def ensure_session(notification)
186
+ if @session_id.nil?
187
+ @session_id = notification.session_id
188
+ return
189
+ end
190
+ handle_session_change(notification.session_id) if notification.session_id != @session_id
191
+ end
192
+
193
+ def handle_session_change(session_id)
194
+ if @session_id.nil?
195
+ @session_id = session_id
196
+ return
197
+ end
198
+ raise SessionNotificationMismatchError.new(@session_id, session_id) unless @auto_reset
199
+
200
+ reset
201
+ @session_id = session_id
202
+ end
203
+
204
+ def apply_update(update)
205
+ case update
206
+ when Schema::ToolCallStart
207
+ state_for(update.tool_call_id).apply_start(update)
208
+ when Schema::ToolCallProgress
209
+ state_for(update.tool_call_id).apply_progress(update)
210
+ when Schema::AgentPlanUpdate
211
+ @plan_entries = Contrib.copy_model_list(update.entries) || []
212
+ when Schema::CurrentModeUpdate
213
+ @current_mode_id = update.current_mode_id
214
+ when Schema::AvailableCommandsUpdate
215
+ @available_commands = Contrib.copy_model_list(update.available_commands) || []
216
+ when Schema::UserMessageChunk
217
+ @user_messages << Contrib.deep_copy_model(update)
218
+ when Schema::AgentMessageChunk
219
+ @agent_messages << Contrib.deep_copy_model(update)
220
+ when Schema::AgentThoughtChunk
221
+ @agent_thoughts << Contrib.deep_copy_model(update)
222
+ end
223
+ end
224
+
225
+ def state_for(tool_call_id)
226
+ @tool_calls[tool_call_id] ||= MutableToolCallState.new(tool_call_id)
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "../schema"
5
+
6
+ module ACP
7
+ module Contrib
8
+ # Sentinel for optional parameters. Use `equal?` to test for it; never serialize it.
9
+ UNSET = Object.new.freeze
10
+
11
+ class MissingToolCallTitleError < ArgumentError
12
+ def initialize
13
+ super("title must be set before sending a ToolCallStart")
14
+ end
15
+ end
16
+
17
+ class UnknownToolCallError < KeyError
18
+ attr_reader :external_id
19
+
20
+ def initialize(external_id)
21
+ @external_id = external_id
22
+ super("Unknown tool call id: #{external_id}")
23
+ end
24
+ end
25
+
26
+ def self.deep_copy_model(value)
27
+ Marshal.load(Marshal.dump(value))
28
+ end
29
+
30
+ def self.copy_model_list(items)
31
+ return nil if items.nil?
32
+
33
+ items.map { |item| deep_copy_model(item) }
34
+ end
35
+
36
+ # Immutable view of a tracked tool call.
37
+ class TrackedToolCallView
38
+ attr_reader :tool_call_id, :title, :name, :kind, :status,
39
+ :content, :locations, :raw_input, :raw_output
40
+
41
+ def initialize(tool_call_id:, title:, name:, kind:, status:,
42
+ content:, locations:, raw_input:, raw_output:)
43
+ @tool_call_id = tool_call_id
44
+ @title = title
45
+ @name = name
46
+ @kind = kind
47
+ @status = status
48
+ @content = content&.freeze
49
+ @locations = locations&.freeze
50
+ @raw_input = raw_input
51
+ @raw_output = raw_output
52
+ freeze
53
+ end
54
+
55
+ def ==(other)
56
+ other.instance_of?(self.class) && to_h == other.to_h
57
+ end
58
+
59
+ alias eql? ==
60
+
61
+ def hash
62
+ [self.class, to_h].hash
63
+ end
64
+
65
+ def to_h
66
+ {
67
+ tool_call_id: @tool_call_id, title: @title, name: @name,
68
+ kind: @kind, status: @status, content: @content,
69
+ locations: @locations, raw_input: @raw_input, raw_output: @raw_output
70
+ }
71
+ end
72
+ end
73
+
74
+ class TrackedToolCall
75
+ attr_reader :tool_call_id
76
+
77
+ def initialize(tool_call_id:, title: nil, name: nil, kind: nil, status: nil,
78
+ content: nil, locations: nil, raw_input: nil, raw_output: nil)
79
+ @tool_call_id = tool_call_id
80
+ @title = title
81
+ @name = name
82
+ @kind = kind
83
+ @status = status
84
+ @content = Contrib.copy_model_list(content)
85
+ @locations = Contrib.copy_model_list(locations)
86
+ @raw_input = raw_input
87
+ @raw_output = raw_output
88
+ @stream_buffer = nil
89
+ end
90
+
91
+ def to_view
92
+ TrackedToolCallView.new(
93
+ tool_call_id: @tool_call_id,
94
+ title: @title,
95
+ name: @name,
96
+ kind: @kind,
97
+ status: @status,
98
+ content: @content&.map { |item| Contrib.deep_copy_model(item) },
99
+ locations: @locations&.map { |loc| Contrib.deep_copy_model(loc) },
100
+ raw_input: @raw_input,
101
+ raw_output: @raw_output
102
+ )
103
+ end
104
+
105
+ def to_tool_call_model
106
+ Schema::ToolCallUpdate.new(
107
+ tool_call_id: @tool_call_id,
108
+ title: @title,
109
+ name: @name,
110
+ kind: @kind,
111
+ status: @status,
112
+ content: Contrib.copy_model_list(@content),
113
+ locations: Contrib.copy_model_list(@locations),
114
+ raw_input: @raw_input,
115
+ raw_output: @raw_output
116
+ )
117
+ end
118
+
119
+ def to_start_model
120
+ raise MissingToolCallTitleError if @title.nil?
121
+
122
+ Schema::ToolCallStart.new(
123
+ tool_call_id: @tool_call_id,
124
+ title: @title,
125
+ name: @name,
126
+ kind: @kind,
127
+ status: @status,
128
+ content: Contrib.copy_model_list(@content),
129
+ locations: Contrib.copy_model_list(@locations),
130
+ raw_input: @raw_input,
131
+ raw_output: @raw_output
132
+ )
133
+ end
134
+
135
+ def update(title: UNSET, name: UNSET, kind: UNSET, status: UNSET,
136
+ content: UNSET, locations: UNSET, raw_input: UNSET, raw_output: UNSET)
137
+ kwargs = { tool_call_id: @tool_call_id }
138
+ unless title.equal?(UNSET)
139
+ @title = title
140
+ kwargs[:title] = @title
141
+ end
142
+ unless name.equal?(UNSET)
143
+ @name = name
144
+ kwargs[:name] = @name
145
+ end
146
+ unless kind.equal?(UNSET)
147
+ @kind = kind
148
+ kwargs[:kind] = @kind
149
+ end
150
+ unless status.equal?(UNSET)
151
+ @status = status
152
+ kwargs[:status] = @status
153
+ end
154
+ unless content.equal?(UNSET)
155
+ @content = Contrib.copy_model_list(content)
156
+ kwargs[:content] = Contrib.copy_model_list(content)
157
+ end
158
+ unless locations.equal?(UNSET)
159
+ @locations = Contrib.copy_model_list(locations)
160
+ kwargs[:locations] = Contrib.copy_model_list(locations)
161
+ end
162
+ unless raw_input.equal?(UNSET)
163
+ @raw_input = raw_input
164
+ kwargs[:raw_input] = @raw_input
165
+ end
166
+ unless raw_output.equal?(UNSET)
167
+ @raw_output = raw_output
168
+ kwargs[:raw_output] = @raw_output
169
+ end
170
+ Schema::ToolCallProgress.new(**kwargs)
171
+ end
172
+
173
+ def append_stream_text(text, title: UNSET, status: UNSET)
174
+ @stream_buffer = "#{@stream_buffer}#{text}"
175
+ content = [Schema::ContentToolCallContent.new(content: Schema::TextContentBlock.new(text: @stream_buffer))]
176
+ update(title: title, status: status, content: content)
177
+ end
178
+ end
179
+
180
+ # Utility for generating ACP tool call updates on the agent side.
181
+ class ToolCallTracker
182
+ def initialize(id_factory: nil)
183
+ @id_factory = id_factory || -> { SecureRandom.hex(16) }
184
+ @calls = {}
185
+ end
186
+
187
+ def start(external_id, title:, name: nil, kind: nil, status: "in_progress",
188
+ content: nil, locations: nil, raw_input: nil, raw_output: nil)
189
+ state = TrackedToolCall.new(
190
+ tool_call_id: @id_factory.call,
191
+ title: title,
192
+ name: name,
193
+ kind: kind,
194
+ status: status,
195
+ content: content,
196
+ locations: locations,
197
+ raw_input: raw_input,
198
+ raw_output: raw_output
199
+ )
200
+ @calls[external_id] = state
201
+ state.to_start_model
202
+ end
203
+
204
+ def progress(external_id, title: UNSET, name: UNSET, kind: UNSET, status: UNSET,
205
+ content: UNSET, locations: UNSET, raw_input: UNSET, raw_output: UNSET)
206
+ require_call(external_id).update(
207
+ title: title, name: name, kind: kind, status: status,
208
+ content: content, locations: locations,
209
+ raw_input: raw_input, raw_output: raw_output
210
+ )
211
+ end
212
+
213
+ def append_stream_text(external_id, text, title: UNSET, status: UNSET)
214
+ require_call(external_id).append_stream_text(text, title: title, status: status)
215
+ end
216
+
217
+ def forget(external_id)
218
+ @calls.delete(external_id)
219
+ end
220
+
221
+ def tracked?(external_id)
222
+ @calls.key?(external_id)
223
+ end
224
+
225
+ def view(external_id)
226
+ require_call(external_id).to_view
227
+ end
228
+
229
+ def tool_call_model(external_id)
230
+ require_call(external_id).to_tool_call_model
231
+ end
232
+
233
+ private
234
+
235
+ def require_call(external_id)
236
+ @calls.fetch(external_id) { raise UnknownToolCallError, external_id }
237
+ end
238
+ end
239
+ end
240
+ end
@@ -12,14 +12,14 @@ module ACP
12
12
  @data = data
13
13
  end
14
14
 
15
- def self.parse_error(data = nil) = new(-32700, "Parse error", data)
16
- def self.invalid_request(data = nil) = new(-32600, "Invalid request", data)
17
- def self.method_not_found(method) = new(-32601, "Method not found", { "method" => method })
18
- def self.invalid_params(data = nil) = new(-32602, "Invalid params", data)
19
- def self.internal_error(data = nil) = new(-32603, "Internal error", data)
20
- def self.request_cancelled(data = nil) = new(-32800, "Request cancelled", data)
21
- def self.auth_required(data = nil) = new(-32000, "Authentication required", data)
22
- def self.resource_not_found(uri = nil) = new(-32002, "Resource not found", uri ? { "uri" => uri } : nil)
15
+ def self.parse_error(data = nil) = new(-32700, "Parse error", data)
16
+ def self.invalid_request(data = nil) = new(-32600, "Invalid request", data)
17
+ def self.method_not_found(method) = new(-32601, "Method not found", { "method" => method })
18
+ def self.invalid_params(data = nil) = new(-32602, "Invalid params", data)
19
+ def self.internal_error(data = nil) = new(-32603, "Internal error", data)
20
+ def self.request_cancelled(data = nil) = new(-32800, "Request cancelled", data)
21
+ def self.auth_required(data = nil) = new(-32000, "Authentication required", data)
22
+ def self.resource_not_found(uri = nil) = new(-32002, "Resource not found", uri ? { "uri" => uri } : nil)
23
23
 
24
24
  def to_error_obj
25
25
  { "code" => @code, "message" => message, "data" => @data }
data/lib/acp/router.rb CHANGED
@@ -24,7 +24,10 @@ module ACP
24
24
  raise RequestError.method_not_found(@method)
25
25
  end
26
26
 
27
- argument = @model ? @model.coerce(params.nil? ? {} : params) : params
27
+ # Do NOT coerce nil -> {}: missing params must fail validation
28
+ # (invalid_params) instead of being silently replaced.
29
+ # Extension routes handle nil themselves (nil -> {} for wire compat).
30
+ argument = @model ? @model.coerce(params) : params
28
31
  result = @handler.call(argument)
29
32
  return result unless @kind == :request && @adapt_result
30
33
 
@@ -54,25 +57,25 @@ module ACP
54
57
  def route_request(method, model, target, *names, optional: false, default_result: nil, normalize: false)
55
58
  handler = resolve_handler(target, names)
56
59
  add_route(Route.new(
57
- method: method,
58
- handler: handler,
59
- kind: :request,
60
- model: model,
61
- optional: optional,
62
- default_result: default_result,
63
- adapt_result: normalize ? NORMALIZE_RESULT : nil
64
- ))
60
+ method: method,
61
+ handler: handler,
62
+ kind: :request,
63
+ model: model,
64
+ optional: optional,
65
+ default_result: default_result,
66
+ adapt_result: normalize ? NORMALIZE_RESULT : nil
67
+ ))
65
68
  end
66
69
 
67
70
  def route_notification(method, model, target, *names)
68
71
  handler = resolve_handler(target, names)
69
72
  add_route(Route.new(
70
- method: method,
71
- handler: handler,
72
- kind: :notification,
73
- model: model,
74
- optional: true
75
- ))
73
+ method: method,
74
+ handler: handler,
75
+ kind: :notification,
76
+ model: model,
77
+ optional: true
78
+ ))
76
79
  end
77
80
 
78
81
  def on_extension_request(&block)