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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +2 -0
- data/lib/acp/agent.rb +47 -5
- data/lib/acp/client.rb +4 -1
- data/lib/acp/connection.rb +66 -27
- data/lib/acp/contrib/permissions.rb +74 -0
- data/lib/acp/contrib/session_state.rb +230 -0
- data/lib/acp/contrib/tool_calls.rb +240 -0
- data/lib/acp/exceptions.rb +8 -8
- data/lib/acp/router.rb +18 -15
- data/lib/acp/schema_base.rb +64 -20
- data/lib/acp/stdio.rb +1 -1
- data/lib/acp/transport.rb +40 -11
- data/lib/acp/version.rb +1 -1
- data/lib/acp/wait.rb +37 -5
- data/lib/acp_sdk_async.rb +3 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec9246d9b39f427c0ce91bc4d9538808c0721f391dd29cef5f0a5cf2856a7328
|
|
4
|
+
data.tar.gz: 2666f26808cc8401f430c18a5ff47199343ea712c96915bd9325a289de817e93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 130265b9d6ba0d8bc97441e8412732dd7118bf1727712288837ee176d38fbb56aea27534eb235341034d8083ac13f28b2898e98da82767d80f3fda01fbaecb44
|
|
7
|
+
data.tar.gz: 77b2eebd91c6a8f0572ecdfc16b6e8bfb902b7d643e4210af9cdd311f074967338e2ddb17eaa520ddeca3d731d72e9799bc856775014f8bb6b039317852d9a31
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.3.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- New `ACP::Contrib` helpers: `ToolCallTracker`, `SessionAccumulator`,
|
|
10
|
+
`PermissionBroker` (+ `default_permission_options`).
|
|
11
|
+
- `Client#set_config_option` alias for `set_session_config_option`
|
|
12
|
+
(short alias for the same wire method).
|
|
13
|
+
- `Agent#create_elicitation` now also accepts `message:` + `mode:`
|
|
14
|
+
kwargs next to the full request hash/model.
|
|
15
|
+
- `Agent#write_text_file` / `#release_terminal` / `#kill_terminal`
|
|
16
|
+
return `nil` on null responses instead of an empty model.
|
|
17
|
+
- RuboCop config (`rake rubocop`), SimpleCov gate (90% minimum),
|
|
18
|
+
GitHub Actions matrix (Ruby 3.2, 3.3, 3.4, 4.0).
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Works with any `async` 2.x: task cancellation supports both
|
|
23
|
+
`Async::Stop` (older) and `Async::Cancel` (newer); timed join no
|
|
24
|
+
longer relies on `Task#wait(timeout:)`. Previously `close` could
|
|
25
|
+
hang forever on older async releases.
|
|
26
|
+
- `MemoryTransport#close` signals EOF to the peer only; local reads
|
|
27
|
+
drain queued messages first.
|
|
28
|
+
- `NdjsonTransport` receive timeout now covers partial lines
|
|
29
|
+
(previously a line without `\n` hung forever).
|
|
30
|
+
- Outgoing observer events fire after a successful send and receive a
|
|
31
|
+
deep copy, so observers can neither see unsent payloads nor mutate
|
|
32
|
+
the wire format.
|
|
33
|
+
- `handle_response` prefers `result` over `error` when both are present
|
|
34
|
+
and resolves `nil` when neither is present.
|
|
35
|
+
- Parallel shutdown of handler tasks instead of sequential grace waits.
|
|
36
|
+
- `Router` no longer coerces missing (`nil`) params to `{}`; they fail
|
|
37
|
+
validation as `invalid_params` instead of being silently replaced.
|
|
38
|
+
- Schema models validate `required` fields in `.new`, reject
|
|
39
|
+
catch-all `Other*` values reserved by known variants
|
|
40
|
+
(`action: accept`, `mode: form`, …), and apply `default_on_error`
|
|
41
|
+
on direct construction too.
|
|
42
|
+
- Schema generator output is byte-identical on every Ruby
|
|
43
|
+
(`Hash#inspect` spacing changed in Ruby 3.4).
|
|
44
|
+
|
|
45
|
+
### Compatibility
|
|
46
|
+
|
|
47
|
+
- Verified: full suite (189 tests) green on Ruby 3.2, 3.3, 3.4 and 4.0,
|
|
48
|
+
with `async` 2.37 and 2.45.
|
|
49
|
+
|
|
50
|
+
## [0.2.0]
|
|
51
|
+
|
|
52
|
+
- Initial Ruby SDK snapshot: typed schema models, JSON-RPC connection
|
|
53
|
+
over NDJSON, client/agent wrappers, stdio process management.
|
data/README.md
CHANGED
|
@@ -4,6 +4,8 @@ Ruby SDK for the [Agent Client Protocol](https://agentclientprotocol.com) (ACP):
|
|
|
4
4
|
|
|
5
5
|
Requires Ruby 3.2+ and runs on [`async`](https://github.com/socketry/async), the same cooperative scheduler used by Falcon. The public API is blocking (`client.prompt(...)`) like a normal Ruby method; inside a reactor those calls yield to other tasks instead of occupying a thread.
|
|
6
6
|
|
|
7
|
+
Inspired by the [official Python ACP SDK](https://github.com/agentclientprotocol/python-sdk).
|
|
8
|
+
|
|
7
9
|
## Installation
|
|
8
10
|
|
|
9
11
|
```ruby
|
data/lib/acp/agent.rb
CHANGED
|
@@ -108,7 +108,7 @@ module ACP
|
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
def write_text_file(session_id:, path:, content:, **meta)
|
|
111
|
-
|
|
111
|
+
request_optional(
|
|
112
112
|
CLIENT_METHODS["fs_write_text_file"], Schema::WriteTextFileRequest, Schema::WriteTextFileResponse,
|
|
113
113
|
session_id: session_id, path: path, content: content, **meta
|
|
114
114
|
)
|
|
@@ -130,7 +130,7 @@ module ACP
|
|
|
130
130
|
end
|
|
131
131
|
|
|
132
132
|
def release_terminal(session_id:, terminal_id:, **meta)
|
|
133
|
-
|
|
133
|
+
request_optional(
|
|
134
134
|
CLIENT_METHODS["terminal_release"], Schema::ReleaseTerminalRequest, Schema::ReleaseTerminalResponse,
|
|
135
135
|
session_id: session_id, terminal_id: terminal_id, **meta
|
|
136
136
|
)
|
|
@@ -145,14 +145,25 @@ module ACP
|
|
|
145
145
|
end
|
|
146
146
|
|
|
147
147
|
def kill_terminal(session_id:, terminal_id:, **meta)
|
|
148
|
-
|
|
148
|
+
request_optional(
|
|
149
149
|
CLIENT_METHODS["terminal_kill"], Schema::KillTerminalRequest, Schema::KillTerminalResponse,
|
|
150
150
|
session_id: session_id, terminal_id: terminal_id, **meta
|
|
151
151
|
)
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
# Accepts either a full request model/hash or message:+mode: kwargs.
|
|
155
|
+
# Mode is one of ElicitationFormSessionMode / ElicitationFormRequestMode /
|
|
156
|
+
# ElicitationUrlSessionMode / ElicitationUrlRequestMode.
|
|
157
|
+
def create_elicitation(request = nil, message: nil, mode: nil, **meta)
|
|
158
|
+
payload =
|
|
159
|
+
if !message.nil? || !mode.nil?
|
|
160
|
+
raise ArgumentError, "message: and mode: are both required" if message.nil? || mode.nil?
|
|
161
|
+
raise ArgumentError, "request must not be given with message:/mode:" unless request.nil?
|
|
162
|
+
|
|
163
|
+
build_elicitation_request(message, mode, meta)
|
|
164
|
+
else
|
|
165
|
+
Schema::CreateElicitationRequest.coerce(request)
|
|
166
|
+
end
|
|
156
167
|
result = @conn.send_request(CLIENT_METHODS["elicitation_create"], payload)
|
|
157
168
|
Schema::CreateElicitationResponse.coerce(result || {})
|
|
158
169
|
end
|
|
@@ -178,6 +189,16 @@ module ACP
|
|
|
178
189
|
response_class.coerce(result || {})
|
|
179
190
|
end
|
|
180
191
|
|
|
192
|
+
# Optional responses: null / non-dict responses become nil
|
|
193
|
+
# instead of an empty model.
|
|
194
|
+
def request_optional(method, request_class, response_class, **kwargs)
|
|
195
|
+
payload = build(request_class, kwargs)
|
|
196
|
+
result = @conn.send_request(method, payload)
|
|
197
|
+
return nil unless result.is_a?(Hash)
|
|
198
|
+
|
|
199
|
+
response_class.coerce(result)
|
|
200
|
+
end
|
|
201
|
+
|
|
181
202
|
def notify(method, request_class, **kwargs)
|
|
182
203
|
@conn.send_notification(method, build(request_class, kwargs))
|
|
183
204
|
end
|
|
@@ -188,6 +209,27 @@ module ACP
|
|
|
188
209
|
model.field_meta = meta if meta
|
|
189
210
|
model
|
|
190
211
|
end
|
|
212
|
+
|
|
213
|
+
def build_elicitation_request(message, mode, meta)
|
|
214
|
+
mode_hash = mode.is_a?(Schema::Base) ? mode.to_h : Schema.serialize(mode)
|
|
215
|
+
mode_hash = mode_hash.transform_keys(&:to_s)
|
|
216
|
+
field_meta = meta.delete(:field_meta) || meta.delete(:meta)
|
|
217
|
+
# The mode models carry no "mode" discriminator (it lives on the
|
|
218
|
+
# request), so infer it from the mode class.
|
|
219
|
+
discriminator =
|
|
220
|
+
case mode
|
|
221
|
+
when Schema::ElicitationFormSessionMode, Schema::ElicitationFormRequestMode then "form"
|
|
222
|
+
when Schema::ElicitationUrlSessionMode, Schema::ElicitationUrlRequestMode then "url"
|
|
223
|
+
else mode_hash["mode"]
|
|
224
|
+
end
|
|
225
|
+
# Merge message + mode fields; the CreateElicitationRequest union
|
|
226
|
+
# dispatches to the correct form/url variant.
|
|
227
|
+
hash = { "message" => message }.merge(mode_hash).merge(meta.compact.transform_keys(&:to_s))
|
|
228
|
+
hash["mode"] = discriminator if discriminator
|
|
229
|
+
request = Schema::CreateElicitationRequest.coerce(hash)
|
|
230
|
+
request.field_meta = field_meta if field_meta
|
|
231
|
+
request
|
|
232
|
+
end
|
|
191
233
|
end
|
|
192
234
|
|
|
193
235
|
def self.run_agent(agent, input: $stdin, output: $stdout, **connection_options)
|
data/lib/acp/client.rb
CHANGED
|
@@ -152,7 +152,7 @@ module ACP
|
|
|
152
152
|
end
|
|
153
153
|
|
|
154
154
|
def set_session_config_option(session_id:, config_id:, value:, **meta)
|
|
155
|
-
request_class = if
|
|
155
|
+
request_class = if [true, false].include?(value)
|
|
156
156
|
Schema::SetSessionConfigOptionBooleanRequest
|
|
157
157
|
else
|
|
158
158
|
Schema::SetSessionConfigOptionSelectRequest
|
|
@@ -163,6 +163,9 @@ module ACP
|
|
|
163
163
|
)
|
|
164
164
|
end
|
|
165
165
|
|
|
166
|
+
# Short alias for the same wire method (session/set_config_option).
|
|
167
|
+
alias set_config_option set_session_config_option
|
|
168
|
+
|
|
166
169
|
def prompt(session_id:, prompt:, **meta)
|
|
167
170
|
request(AGENT_METHODS["session_prompt"], Schema::PromptRequest, Schema::PromptResponse,
|
|
168
171
|
session_id: session_id, prompt: prompt, **meta)
|
data/lib/acp/connection.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "set"
|
|
5
4
|
require "async/queue"
|
|
6
5
|
require_relative "exceptions"
|
|
7
6
|
require_relative "schema_base"
|
|
@@ -34,7 +33,7 @@ module ACP
|
|
|
34
33
|
end
|
|
35
34
|
|
|
36
35
|
def closed?
|
|
37
|
-
@closed
|
|
36
|
+
@mutex.synchronize { @closed }
|
|
38
37
|
end
|
|
39
38
|
|
|
40
39
|
def add_observer(callable = nil, &block)
|
|
@@ -74,11 +73,15 @@ module ACP
|
|
|
74
73
|
end
|
|
75
74
|
|
|
76
75
|
def send_request(method, params = nil, timeout: nil)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
request_id = next_id
|
|
76
|
+
request_id = nil
|
|
80
77
|
promise = Wait::Promise.new
|
|
81
|
-
@mutex.synchronize
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
raise ConnectionError, "Connection closed" if @closed
|
|
80
|
+
|
|
81
|
+
request_id = @next_id
|
|
82
|
+
@next_id += 1
|
|
83
|
+
@pending[request_id] = promise
|
|
84
|
+
end
|
|
82
85
|
|
|
83
86
|
payload = { "jsonrpc" => "2.0", "id" => request_id, "method" => method }
|
|
84
87
|
payload["params"] = serialize(params) unless params.nil?
|
|
@@ -99,7 +102,7 @@ module ACP
|
|
|
99
102
|
end
|
|
100
103
|
|
|
101
104
|
def send_notification(method, params = nil)
|
|
102
|
-
raise ConnectionError, "Connection closed" if @closed
|
|
105
|
+
@mutex.synchronize { raise ConnectionError, "Connection closed" if @closed }
|
|
103
106
|
|
|
104
107
|
payload = { "jsonrpc" => "2.0", "method" => method }
|
|
105
108
|
payload["params"] = serialize(params) unless params.nil?
|
|
@@ -110,14 +113,23 @@ module ACP
|
|
|
110
113
|
return true unless Wait.alive?(@notification_worker)
|
|
111
114
|
|
|
112
115
|
latch = Wait::Latch.new
|
|
113
|
-
|
|
116
|
+
begin
|
|
117
|
+
@notification_queue.push(latch)
|
|
118
|
+
rescue ::Async::Queue::ClosedError
|
|
119
|
+
return false
|
|
120
|
+
end
|
|
114
121
|
latch.wait(timeout)
|
|
115
122
|
end
|
|
116
123
|
|
|
117
124
|
def close
|
|
118
|
-
|
|
125
|
+
already_closed = @mutex.synchronize do
|
|
126
|
+
next true if @closed
|
|
127
|
+
|
|
128
|
+
@closed = true
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
return if already_closed
|
|
119
132
|
|
|
120
|
-
@closed = true
|
|
121
133
|
reject_all(ConnectionError.new("Connection closed"))
|
|
122
134
|
begin
|
|
123
135
|
@transport.close
|
|
@@ -143,13 +155,15 @@ module ACP
|
|
|
143
155
|
end
|
|
144
156
|
|
|
145
157
|
def write(payload)
|
|
146
|
-
|
|
158
|
+
# Notify AFTER successful send: notifying before would expose observers
|
|
159
|
+
# to messages that never hit the wire and let them mutate the payload.
|
|
147
160
|
@transport.send_message(payload)
|
|
161
|
+
notify_observers(:outgoing, payload)
|
|
148
162
|
end
|
|
149
163
|
|
|
150
164
|
def receive_loop
|
|
151
165
|
loop do
|
|
152
|
-
break if
|
|
166
|
+
break if closed?
|
|
153
167
|
|
|
154
168
|
message = begin
|
|
155
169
|
@transport.receive_message
|
|
@@ -159,7 +173,7 @@ module ACP
|
|
|
159
173
|
rescue ConnectionError => e
|
|
160
174
|
ACP.logger.debug("acp: receive failed: #{e.message}")
|
|
161
175
|
break
|
|
162
|
-
rescue ::
|
|
176
|
+
rescue Wait::TASK_STOPPED
|
|
163
177
|
break
|
|
164
178
|
end
|
|
165
179
|
break if message.nil?
|
|
@@ -210,7 +224,7 @@ module ACP
|
|
|
210
224
|
payload["error"] = RequestError.invalid_params(
|
|
211
225
|
"errors" => [{ "message" => e.message, "loc" => e.path }]
|
|
212
226
|
).to_error_obj
|
|
213
|
-
rescue ::
|
|
227
|
+
rescue Wait::TASK_STOPPED
|
|
214
228
|
return
|
|
215
229
|
rescue StandardError => e
|
|
216
230
|
ACP.logger.error("acp: handler for #{message['method']} failed: #{e.class}: #{e.message}")
|
|
@@ -219,19 +233,19 @@ module ACP
|
|
|
219
233
|
write(payload)
|
|
220
234
|
rescue ConnectionError => e
|
|
221
235
|
ACP.logger.debug("acp: could not send response for #{message['method']}: #{e.message}")
|
|
222
|
-
rescue ::
|
|
236
|
+
rescue Wait::TASK_STOPPED
|
|
223
237
|
nil
|
|
224
238
|
end
|
|
225
239
|
|
|
226
240
|
def respond_error(id, error)
|
|
227
241
|
write({ "jsonrpc" => "2.0", "id" => id, "error" => error.to_error_obj })
|
|
228
|
-
rescue ConnectionError, ::
|
|
242
|
+
rescue ConnectionError, Wait::TASK_STOPPED
|
|
229
243
|
nil
|
|
230
244
|
end
|
|
231
245
|
|
|
232
246
|
def run_notification(message)
|
|
233
247
|
@handler.call(message["method"], message["params"], true)
|
|
234
|
-
rescue ::
|
|
248
|
+
rescue Wait::TASK_STOPPED
|
|
235
249
|
nil
|
|
236
250
|
rescue StandardError => e
|
|
237
251
|
ACP.logger.error("acp: notification handler for #{message['method']} failed: #{e.class}: #{e.message}")
|
|
@@ -243,12 +257,18 @@ module ACP
|
|
|
243
257
|
ACP.logger.debug("acp: response for unknown request id #{message['id'].inspect}")
|
|
244
258
|
return
|
|
245
259
|
end
|
|
260
|
+
return if promise.settled?
|
|
246
261
|
|
|
247
|
-
|
|
262
|
+
# JSON-RPC result and error are mutually exclusive; when both are present
|
|
263
|
+
# prefer result.
|
|
264
|
+
if message.key?("result")
|
|
265
|
+
promise.resolve(message["result"])
|
|
266
|
+
elsif message.key?("error")
|
|
248
267
|
error = message["error"] || {}
|
|
268
|
+
error = {} unless error.is_a?(Hash)
|
|
249
269
|
promise.reject(RequestError.new(error["code"] || -32603, error["message"] || "Error", error["data"]))
|
|
250
270
|
else
|
|
251
|
-
promise.resolve(
|
|
271
|
+
promise.resolve(nil)
|
|
252
272
|
end
|
|
253
273
|
end
|
|
254
274
|
|
|
@@ -258,14 +278,30 @@ module ACP
|
|
|
258
278
|
@pending.clear
|
|
259
279
|
items
|
|
260
280
|
end
|
|
261
|
-
pending.each
|
|
281
|
+
pending.each do |promise|
|
|
282
|
+
promise.reject(error) unless promise.settled?
|
|
283
|
+
rescue StandardError
|
|
284
|
+
nil
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def deep_copy_message(message)
|
|
289
|
+
JSON.parse(JSON.generate(message))
|
|
290
|
+
rescue StandardError
|
|
291
|
+
begin
|
|
292
|
+
Marshal.load(Marshal.dump(message))
|
|
293
|
+
rescue StandardError
|
|
294
|
+
message.is_a?(Hash) ? message.dup : message
|
|
295
|
+
end
|
|
262
296
|
end
|
|
263
297
|
|
|
264
298
|
def notify_observers(direction, message)
|
|
265
299
|
observers = @mutex.synchronize { @observers.dup }
|
|
266
300
|
return if observers.empty?
|
|
267
301
|
|
|
268
|
-
|
|
302
|
+
# Deep-copy once so observers cannot mutate the wire payload.
|
|
303
|
+
snapshot = deep_copy_message(message)
|
|
304
|
+
event = StreamEvent.new(direction: direction, message: snapshot)
|
|
269
305
|
observers.each do |observer|
|
|
270
306
|
observer.call(event)
|
|
271
307
|
rescue StandardError => e
|
|
@@ -297,7 +333,7 @@ module ACP
|
|
|
297
333
|
|
|
298
334
|
begin
|
|
299
335
|
@notification_queue.push(nil)
|
|
300
|
-
rescue ::Async::Queue::ClosedError
|
|
336
|
+
rescue ::Async::Queue::ClosedError
|
|
301
337
|
nil
|
|
302
338
|
end
|
|
303
339
|
return if Wait.current?(worker)
|
|
@@ -306,6 +342,8 @@ module ACP
|
|
|
306
342
|
end
|
|
307
343
|
|
|
308
344
|
def spawn_worker(name, &block)
|
|
345
|
+
return nil if closed?
|
|
346
|
+
|
|
309
347
|
Wait.spawn(name) do
|
|
310
348
|
me = Wait.current_worker
|
|
311
349
|
@mutex.synchronize { @workers << me }
|
|
@@ -327,11 +365,12 @@ module ACP
|
|
|
327
365
|
@workers.clear
|
|
328
366
|
list
|
|
329
367
|
end
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
368
|
+
# Stop all first, then join: parallel shutdown instead of N * grace.
|
|
369
|
+
# rubocop:disable Style/CombinableLoops -- merging the loops would serialize stop+join per worker
|
|
370
|
+
pending = workers.reject { |worker| Wait.current?(worker) }
|
|
371
|
+
pending.each { |worker| Wait.stop(worker) }
|
|
372
|
+
pending.each { |worker| Wait.join(worker, @worker_grace) }
|
|
373
|
+
# rubocop:enable Style/CombinableLoops
|
|
335
374
|
listener = @listen_worker
|
|
336
375
|
return if listener.nil? || Wait.current?(listener)
|
|
337
376
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../schema"
|
|
4
|
+
require_relative "tool_calls"
|
|
5
|
+
|
|
6
|
+
module ACP
|
|
7
|
+
module Contrib
|
|
8
|
+
class PermissionBrokerError < StandardError; end
|
|
9
|
+
|
|
10
|
+
class MissingToolCallError < PermissionBrokerError
|
|
11
|
+
def initialize
|
|
12
|
+
super("tool_call must be provided when no ToolCallTracker is configured")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class MissingPermissionOptionsError < PermissionBrokerError
|
|
17
|
+
def initialize
|
|
18
|
+
super("PermissionBroker requires at least one permission option")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.default_permission_options
|
|
23
|
+
[
|
|
24
|
+
Schema::PermissionOption.new(option_id: "approve", name: "Approve", kind: "allow_once"),
|
|
25
|
+
Schema::PermissionOption.new(option_id: "approve_for_session", name: "Approve for session", kind: "allow_always"),
|
|
26
|
+
Schema::PermissionOption.new(option_id: "reject", name: "Reject", kind: "reject_once")
|
|
27
|
+
]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Helper for issuing permission requests tied to tracked tool calls.
|
|
31
|
+
# The requester is a sync callable: response = requester.call(request).
|
|
32
|
+
class PermissionBroker
|
|
33
|
+
def initialize(session_id, requester, tracker: nil, default_options: nil)
|
|
34
|
+
@session_id = session_id
|
|
35
|
+
@requester = requester
|
|
36
|
+
@tracker = tracker
|
|
37
|
+
@default_options = (default_options || Contrib.default_permission_options).map do |option|
|
|
38
|
+
Contrib.deep_copy_model(option)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def request_for(external_id, description: nil, options: nil, content: nil, tool_call: nil)
|
|
43
|
+
resolved =
|
|
44
|
+
if tool_call.nil?
|
|
45
|
+
raise MissingToolCallError if @tracker.nil?
|
|
46
|
+
|
|
47
|
+
@tracker.tool_call_model(external_id)
|
|
48
|
+
else
|
|
49
|
+
Contrib.deep_copy_model(tool_call)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
resolved.content = Contrib.copy_model_list(content) unless content.nil?
|
|
53
|
+
|
|
54
|
+
if description
|
|
55
|
+
existing = resolved.content || []
|
|
56
|
+
existing << Schema::ContentToolCallContent.new(
|
|
57
|
+
content: Schema::TextContentBlock.new(text: description)
|
|
58
|
+
)
|
|
59
|
+
resolved.content = existing
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
option_set = (options || @default_options).map { |option| Contrib.deep_copy_model(option) }
|
|
63
|
+
raise MissingPermissionOptionsError if option_set.empty?
|
|
64
|
+
|
|
65
|
+
request = Schema::RequestPermissionRequest.new(
|
|
66
|
+
session_id: @session_id,
|
|
67
|
+
tool_call: resolved,
|
|
68
|
+
options: option_set
|
|
69
|
+
)
|
|
70
|
+
@requester.call(request)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|