sai-sdk 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +246 -0
- data/lib/sai/client.rb +183 -0
- data/lib/sai/control.rb +153 -0
- data/lib/sai/errors.rb +109 -0
- data/lib/sai/http.rb +103 -0
- data/lib/sai/learn.rb +137 -0
- data/lib/sai/machines.rb +48 -0
- data/lib/sai/sessions.rb +333 -0
- data/lib/sai/tenants.rb +174 -0
- data/lib/sai/turns.rb +72 -0
- data/lib/sai/types.rb +688 -0
- data/lib/sai/util.rb +33 -0
- data/lib/sai/version.rb +5 -0
- data/lib/sai.rb +25 -0
- metadata +57 -0
data/lib/sai/sessions.rb
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Sai
|
|
6
|
+
# Sessions: a conversation with Sai on one computer. Sai decides every click.
|
|
7
|
+
class Sessions
|
|
8
|
+
# @api private
|
|
9
|
+
def initialize(http, machines)
|
|
10
|
+
@http = http
|
|
11
|
+
@machines = machines
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Start a fresh conversation on a computer: the next message has no memory of earlier ones.
|
|
15
|
+
# +machine+ (a Machine or its id) may be left out with one computer.
|
|
16
|
+
# @return [Session]
|
|
17
|
+
def create(machine: nil)
|
|
18
|
+
machine_id = resolve_machine(machine)
|
|
19
|
+
session_id = nil
|
|
20
|
+
begin
|
|
21
|
+
d = @http.json("POST", "/v1/agents/new-session", json: { "machineId" => machine_id, "channel" => "api" })
|
|
22
|
+
session_id = Parse.str(Parse.obj(d)["sessionId"])
|
|
23
|
+
rescue NotFoundError
|
|
24
|
+
# A free account's computer between claims has no conversation to rotate; the first
|
|
25
|
+
# message starts one.
|
|
26
|
+
end
|
|
27
|
+
Session.new(@http, machine_id, session_id)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The caller's sessions on a computer (desktop, CLI and API), most recent first (up to 20).
|
|
31
|
+
# +active+ marks the API channel's current one, which the next message without a session goes to.
|
|
32
|
+
# @return [Array<SessionSummary>]
|
|
33
|
+
def list(machine: nil)
|
|
34
|
+
machine_id = resolve_machine(machine)
|
|
35
|
+
d = @http.json("GET", "/v1/agents/sessions", params: { "machineId" => machine_id, "channel" => "api" })
|
|
36
|
+
Parse.list(Parse.obj(d)["sessions"]).map { |s| SessionSummary.parse(s) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Reattach to a session started elsewhere, e.g. to poll or abort it. Makes no request.
|
|
40
|
+
# @return [Session]
|
|
41
|
+
def resume(session_id, machine: nil)
|
|
42
|
+
Session.new(@http, machine ? Util.id_of(machine) : nil, session_id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def resolve_machine(machine)
|
|
48
|
+
return Util.id_of(machine) if machine
|
|
49
|
+
|
|
50
|
+
machines = @machines.list
|
|
51
|
+
return machines[0].id if machines.length == 1
|
|
52
|
+
|
|
53
|
+
if machines.empty?
|
|
54
|
+
raise BadRequestError.new("No computer on this account. Create one in the Sai app or the portal.",
|
|
55
|
+
status: 400)
|
|
56
|
+
end
|
|
57
|
+
names = machines.map { |m| m.name ? "#{m.id} (#{m.name})" : m.id }.join(", ")
|
|
58
|
+
raise BadRequestError.new("Several computers on this account; pass machine. Candidates: #{names}",
|
|
59
|
+
status: 400)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A conversation with Sai. Each +send+ is a turn; turns share context.
|
|
64
|
+
class Session
|
|
65
|
+
attr_accessor :id, :machine_id
|
|
66
|
+
|
|
67
|
+
# @api private
|
|
68
|
+
def initialize(http, machine_id, id)
|
|
69
|
+
@http = http
|
|
70
|
+
@machine_id = machine_id
|
|
71
|
+
@id = id
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Give Sai a task, or steer the running one. Returns a Turn at once. Sent while a turn is
|
|
75
|
+
# running, the message steers that turn (+turn.queued+ is true).
|
|
76
|
+
#
|
|
77
|
+
# Any of +inputs+, +output_schema+ or +success_criteria+ (a string, or a list of Check) makes
|
|
78
|
+
# the message structured: +wait+ then also returns the turn's record in +result.record+. A
|
|
79
|
+
# structured message, +record_trajectory+ and +thinking_level+ (+off+, +low+, +medium+,
|
|
80
|
+
# +high+, +xhigh+) are refused with ConflictError (+session_busy+) while a turn is running.
|
|
81
|
+
# The same +idempotency_key+ again returns the earlier turn and sends nothing. +autonomous+
|
|
82
|
+
# runs the turn without approval cards, as if "Trust for this task" had been answered.
|
|
83
|
+
# @return [Turn]
|
|
84
|
+
def send(message, model: nil, attachments: nil, inputs: nil, output_schema: nil, success_criteria: nil,
|
|
85
|
+
idempotency_key: nil, record_trajectory: false, thinking_level: nil, autonomous: false)
|
|
86
|
+
body = { "message" => message, "wait" => false }
|
|
87
|
+
body["machineId"] = machine_id if machine_id && !machine_id.empty?
|
|
88
|
+
body["sessionId"] = id if id && !id.empty?
|
|
89
|
+
body["model"] = model if model && !model.empty?
|
|
90
|
+
body["attachments"] = attachments.map(&:raw) if attachments && !attachments.empty?
|
|
91
|
+
body["autonomous"] = true if autonomous
|
|
92
|
+
options = {}
|
|
93
|
+
options["inputs"] = inputs unless inputs.nil?
|
|
94
|
+
options["output_schema"] = output_schema unless output_schema.nil?
|
|
95
|
+
options["success_criteria"] = Check.list_to_api(success_criteria) unless success_criteria.nil?
|
|
96
|
+
options["idempotency_key"] = idempotency_key unless idempotency_key.nil?
|
|
97
|
+
options["recordTrajectory"] = true if record_trajectory
|
|
98
|
+
options["thinkingLevel"] = thinking_level unless thinking_level.nil?
|
|
99
|
+
body["options"] = options unless options.empty?
|
|
100
|
+
structured = [inputs, output_schema, success_criteria].any? { |v| !v.nil? }
|
|
101
|
+
d = Parse.obj(@http.json("POST", "/v1/agents/message", json: body))
|
|
102
|
+
if Parse.truthy(d["idempotent_replay"])
|
|
103
|
+
# The key was used before: this is that turn, and nothing was sent.
|
|
104
|
+
record = TurnRecord.parse(d)
|
|
105
|
+
self.id = record.session_id || id
|
|
106
|
+
self.machine_id = record.machine_id.empty? ? machine_id : record.machine_id
|
|
107
|
+
return Turn.new(self, @http, queued: false, id: record.id, structured: structured, replay: record)
|
|
108
|
+
end
|
|
109
|
+
session_id = Parse.str(d["sessionId"])
|
|
110
|
+
if session_id.nil? || session_id.empty?
|
|
111
|
+
# Handled without starting a task (e.g. a plain acknowledgement): the reply is in reply,
|
|
112
|
+
# or in error on servers before reply existed.
|
|
113
|
+
text = Parse.str(d["reply"]) || Parse.str(d["error"]) || ""
|
|
114
|
+
return Turn.new(self, @http, queued: false, done: Turns.local_result(id || "", text))
|
|
115
|
+
end
|
|
116
|
+
self.id = session_id
|
|
117
|
+
self.machine_id = d["machineId"] if d["machineId"].is_a?(String)
|
|
118
|
+
Turn.new(self, @http, queued: Parse.truthy(d["queued"]), id: Parse.str(d["turnId"]), structured: structured)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# +send+ then +wait+: the answer, or where it stopped. Takes +send+'s options, plus
|
|
122
|
+
# +on_approval:+ (or a block) and +timeout:+ and +poll_wait:+ as in Turn#wait.
|
|
123
|
+
# @return [TurnResult]
|
|
124
|
+
def run(message, on_approval: nil, timeout: nil, poll_wait: nil, **send_options, &block)
|
|
125
|
+
send(message, **send_options).wait(on_approval: on_approval, timeout: timeout, poll_wait: poll_wait, &block)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Stop the running turn. Pending approvals are denied.
|
|
129
|
+
# @return [Boolean] false if nothing was running
|
|
130
|
+
def abort
|
|
131
|
+
return false if id.nil? || id.empty?
|
|
132
|
+
|
|
133
|
+
d = @http.json("POST", "/v1/agents/abort", json: { "sessionId" => id })
|
|
134
|
+
Parse.truthy(Parse.obj(d)["aborted"])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Answer an approval (an Approval or its id): +yes+, +no+, or +task+ (allow for the rest of
|
|
138
|
+
# the task). A +choice+ approval needs +selections+, one list of option values per question.
|
|
139
|
+
# Raises LinkOnlyApprovalError (with +approval_url+) when only a browser can answer it.
|
|
140
|
+
# @return [String] +approved+, +approved_task+ or +denied+
|
|
141
|
+
def respond(approval, response = "yes", selections: nil)
|
|
142
|
+
if approval.is_a?(Approval) && approval.is_link_only
|
|
143
|
+
raise LinkOnlyApprovalError.new("This approval must be completed in a browser. Open approval.url.",
|
|
144
|
+
status: 409, code: "link_only", body: { "approvalUrl" => approval.url })
|
|
145
|
+
end
|
|
146
|
+
body = { "approvalId" => approval.is_a?(Approval) ? approval.id : approval, "response" => response }
|
|
147
|
+
body["selections"] = selections unless selections.nil?
|
|
148
|
+
d = @http.json("POST", "/v1/agents/approve", json: body)
|
|
149
|
+
Parse.str(Parse.obj(d)["status"]) || ""
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def inspect
|
|
153
|
+
"#<Sai::Session id=#{id.inspect} machine_id=#{machine_id.inspect}>"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# One message's work. Iterate +events+ to watch it, or +wait+ for the result. +id+ is the turn
|
|
158
|
+
# the message started, or the running turn it joined; nil when the message was answered
|
|
159
|
+
# without one.
|
|
160
|
+
class Turn
|
|
161
|
+
POLL_WAIT = 30
|
|
162
|
+
# Turns that failed for these reasons still have the agent's answer.
|
|
163
|
+
ANSWERED_REASONS = %w[output_invalid criteria_not_met criteria_unchecked].freeze
|
|
164
|
+
private_constant :ANSWERED_REASONS
|
|
165
|
+
|
|
166
|
+
attr_reader :id, :queued, :session
|
|
167
|
+
|
|
168
|
+
# @api private
|
|
169
|
+
def initialize(session, http, queued:, done: nil, id: nil, structured: false, replay: nil)
|
|
170
|
+
@session = session
|
|
171
|
+
@http = http
|
|
172
|
+
@queued = queued
|
|
173
|
+
@last = done
|
|
174
|
+
@id = id
|
|
175
|
+
@structured = structured
|
|
176
|
+
@replay = replay
|
|
177
|
+
@cursor = nil
|
|
178
|
+
# answered without starting a task, or an idempotent replay: no events to poll
|
|
179
|
+
@local = !done.nil? || !replay.nil?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# +running+ until a poll says otherwise; see TURN_STATUSES.
|
|
183
|
+
def status
|
|
184
|
+
@last ? @last.status : "running"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The last known state, or nil before the first poll.
|
|
188
|
+
# @return [TurnResult, nil]
|
|
189
|
+
def result
|
|
190
|
+
@last
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Yield events until the turn is idle or errors, through +needs_approval+: answer with
|
|
194
|
+
# +turn.respond(ev.approval, ...)+ inside the loop. Without +raw+, framing, reasoning and
|
|
195
|
+
# tool-call frames are skipped. Without a block, returns an Enumerator.
|
|
196
|
+
# @yieldparam event [Event]
|
|
197
|
+
def events(raw: false, poll_wait: POLL_WAIT)
|
|
198
|
+
return enum_for(:events, raw: raw, poll_wait: poll_wait) unless block_given?
|
|
199
|
+
return if @local
|
|
200
|
+
|
|
201
|
+
loop do
|
|
202
|
+
page = poll(poll_wait)
|
|
203
|
+
Parse.list(page["events"]).each do |e|
|
|
204
|
+
ev = Event.parse(Parse.obj(e))
|
|
205
|
+
yield ev if raw || ev.kind != "other"
|
|
206
|
+
end
|
|
207
|
+
break if %w[idle error].include?(page["status"])
|
|
208
|
+
end
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Wait until the turn is done, or stops on an approval nobody answered. Without a handler,
|
|
213
|
+
# returns with +status == "needs_approval"+ and the +approval+. With one (+on_approval:+ or a
|
|
214
|
+
# block), its answer is sent and waiting continues: a response (+"yes"+, +"no"+, +"task"+),
|
|
215
|
+
# a +[response, selections]+ pair for a choice, or nil to leave the approval to someone else
|
|
216
|
+
# and keep waiting. For a structured message it then waits for the turn's record. +timeout+
|
|
217
|
+
# (seconds) raises Timeout::Error with the turn still running; +poll_wait+ is the seconds the
|
|
218
|
+
# server may hold one poll (it caps at 60).
|
|
219
|
+
# @yieldparam approval [Approval]
|
|
220
|
+
# @return [TurnResult]
|
|
221
|
+
def wait(on_approval: nil, timeout: nil, poll_wait: nil, &block)
|
|
222
|
+
handler = on_approval || block
|
|
223
|
+
deadline = timeout.nil? ? nil : Util.monotonic + timeout
|
|
224
|
+
if @replay
|
|
225
|
+
record = @replay.done ? @replay : Turns.wait_record(@http, @replay.id, deadline)
|
|
226
|
+
@last = replay_result(record, record.session_id || session.id || "")
|
|
227
|
+
return @last
|
|
228
|
+
end
|
|
229
|
+
return @last if @local
|
|
230
|
+
|
|
231
|
+
answered = {}
|
|
232
|
+
loop do
|
|
233
|
+
wait_s = poll_wait || POLL_WAIT
|
|
234
|
+
if deadline
|
|
235
|
+
left = deadline - Util.monotonic
|
|
236
|
+
raise Timeout::Error, "Turn still #{status} after #{timeout}s (session #{session.id})." if left <= 0
|
|
237
|
+
|
|
238
|
+
wait_s = [1, [wait_s, left.floor].min].max
|
|
239
|
+
end
|
|
240
|
+
page = poll(wait_s)
|
|
241
|
+
if %w[idle error].include?(page["status"])
|
|
242
|
+
@last.record = Turns.wait_record(@http, id, deadline) if @structured && id
|
|
243
|
+
return @last
|
|
244
|
+
end
|
|
245
|
+
approval = @last&.approval
|
|
246
|
+
next if page["status"] != "needs_approval" || approval.nil?
|
|
247
|
+
return @last unless handler
|
|
248
|
+
next if answered[approval.id]
|
|
249
|
+
|
|
250
|
+
answer = handler.call(approval)
|
|
251
|
+
answered[approval.id] = true
|
|
252
|
+
next if answer.nil?
|
|
253
|
+
|
|
254
|
+
response, selections = answer.is_a?(Array) ? answer : [answer, nil]
|
|
255
|
+
begin
|
|
256
|
+
session.respond(approval, response, selections: selections)
|
|
257
|
+
rescue ConflictError
|
|
258
|
+
# answered elsewhere or expired; the next poll shows the new state
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Answer an approval; see Session#respond.
|
|
264
|
+
def respond(approval, response = "yes", selections: nil)
|
|
265
|
+
session.respond(approval, response, selections: selections)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Stop the running turn; see Session#abort.
|
|
269
|
+
def abort
|
|
270
|
+
session.abort
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def inspect
|
|
274
|
+
"#<Sai::Turn id=#{id.inspect} queued=#{queued} status=#{status.inspect}>"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
def poll(wait_s)
|
|
280
|
+
page = nil
|
|
281
|
+
loop do
|
|
282
|
+
params = { "sessionId" => session.id || "", "wait" => wait_s, "since" => @cursor }
|
|
283
|
+
page = Parse.obj(@http.json("GET", "/v1/agents/events", params: params, timeout: wait_s + 30))
|
|
284
|
+
break
|
|
285
|
+
rescue RateLimitError => e
|
|
286
|
+
sleep(e.retry_after.nil? ? 30 : e.retry_after)
|
|
287
|
+
end
|
|
288
|
+
@cursor = Parse.str(page["cursor"]) || @cursor
|
|
289
|
+
error = @last&.error
|
|
290
|
+
error_code = @last&.error_code
|
|
291
|
+
Parse.list(page["events"]).each do |e|
|
|
292
|
+
next unless e.is_a?(Hash) && e["type"] == "error"
|
|
293
|
+
|
|
294
|
+
error = e["errorText"] if e["errorText"].is_a?(String)
|
|
295
|
+
error_code = e["code"] if e["code"].is_a?(String)
|
|
296
|
+
end
|
|
297
|
+
@last = TurnResult.new(
|
|
298
|
+
status: Parse.str(page["status"]) || "running",
|
|
299
|
+
text: Parse.str(page["text"]) || "",
|
|
300
|
+
session_id: Parse.str(page["sessionId"]) || session.id || "",
|
|
301
|
+
approval: page["approval"].is_a?(Hash) ? Approval.parse(page["approval"]) : nil,
|
|
302
|
+
error: error,
|
|
303
|
+
error_code: error_code,
|
|
304
|
+
usage: Parse.usage(page["usage"]),
|
|
305
|
+
session_usage: Parse.usage(page["sessionUsage"]),
|
|
306
|
+
record: nil
|
|
307
|
+
)
|
|
308
|
+
page
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# The result the live path would have returned for this finished turn, from its record.
|
|
312
|
+
def replay_result(record, session_id)
|
|
313
|
+
answer = record.raw_answer.nil? || record.raw_answer.empty? ? output_text(record.output) : record.raw_answer
|
|
314
|
+
result = Turns.local_result(session_id, answer)
|
|
315
|
+
if record.status == "failed" && !ANSWERED_REASONS.include?(record.reason_code || "")
|
|
316
|
+
result.status = "error"
|
|
317
|
+
result.text = ""
|
|
318
|
+
result.error = record.raw_answer.nil? || record.raw_answer.empty? ? "The turn failed (#{record.reason_code || 'unknown'})." : record.raw_answer
|
|
319
|
+
result.error_code = record.error_code
|
|
320
|
+
end
|
|
321
|
+
result.usage = record.usage
|
|
322
|
+
result.record = record
|
|
323
|
+
result
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# +output+ as text: a plain message's answer as is, an +output_schema+ object as JSON.
|
|
327
|
+
def output_text(output)
|
|
328
|
+
return "" if output.nil?
|
|
329
|
+
|
|
330
|
+
output.is_a?(String) ? output : JSON.generate(output)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
data/lib/sai/tenants.rb
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Sai
|
|
6
|
+
# Tenants: your customers, under an Organisation. Every call takes an Organisation Key. A tenant
|
|
7
|
+
# argument is a Tenant or its id.
|
|
8
|
+
class Tenants
|
|
9
|
+
# +client+ exchanges again when its token has less than this left, in milliseconds.
|
|
10
|
+
TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000
|
|
11
|
+
UNSET = Object.new.freeze
|
|
12
|
+
private_constant :UNSET
|
|
13
|
+
|
|
14
|
+
# @api private
|
|
15
|
+
def initialize(http, org_id, with_credential)
|
|
16
|
+
@http = http
|
|
17
|
+
@org_id = org_id
|
|
18
|
+
@with_credential = with_credential
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# +external_id+ is your id for the customer, unique in the Organisation; it cannot change.
|
|
22
|
+
# @return [Tenant]
|
|
23
|
+
def create(name, external_id: nil, metadata: nil)
|
|
24
|
+
body = { "name" => name }
|
|
25
|
+
body["externalId"] = external_id unless external_id.nil?
|
|
26
|
+
body["metadata"] = metadata unless metadata.nil?
|
|
27
|
+
Tenant.parse(Parse.obj(@http.json("POST", "/v1/tenants", json: body)))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# One page, newest first. +external_id+ finds the Tenant with that id (zero or one) and cannot
|
|
31
|
+
# be combined with +status+.
|
|
32
|
+
# @return [TenantPage]
|
|
33
|
+
def list(status: nil, external_id: nil, limit: nil, cursor: nil)
|
|
34
|
+
params = { "status" => status, "externalId" => external_id, "limit" => limit, "cursor" => cursor }
|
|
35
|
+
d = Parse.obj(@http.json("GET", "/v1/tenants", params: params))
|
|
36
|
+
TenantPage.new(tenants: Parse.list(d["tenants"]).map { |t| Tenant.parse(Parse.obj(t)) },
|
|
37
|
+
next_cursor: Parse.str(d["nextCursor"]))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Tenant]
|
|
41
|
+
def get(tenant)
|
|
42
|
+
Tenant.parse(Parse.obj(@http.json("GET", self.class.path(tenant))))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Sends only what is given. +metadata+ replaces the whole map. +status: "disabled"+ stops the
|
|
46
|
+
# Tenant's tokens and machines; +"active"+ brings it back. +cloud_machine_limit+ (0 to 1000)
|
|
47
|
+
# caps the Tenant's cloud machines, and +cloud_machine_limit: nil+ removes the cap.
|
|
48
|
+
# +residential_proxy+ has no effect yet. Only the Organisation Key or an owner may set these two.
|
|
49
|
+
# @return [Tenant]
|
|
50
|
+
def update(tenant, name: nil, metadata: nil, status: nil, cloud_machine_limit: UNSET, residential_proxy: nil)
|
|
51
|
+
body = {}
|
|
52
|
+
body["name"] = name unless name.nil?
|
|
53
|
+
body["metadata"] = metadata unless metadata.nil?
|
|
54
|
+
body["status"] = status unless status.nil?
|
|
55
|
+
body["cloudMachineLimit"] = cloud_machine_limit unless cloud_machine_limit.equal?(UNSET)
|
|
56
|
+
body["residentialProxy"] = residential_proxy unless residential_proxy.nil?
|
|
57
|
+
Tenant.parse(Parse.obj(@http.json("PATCH", self.class.path(tenant), json: body)))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Disables the Tenant and revokes every enrolled machine. Cloud machines stay and hibernate:
|
|
61
|
+
# delete them with +tenant_machines.delete+.
|
|
62
|
+
# @return [nil]
|
|
63
|
+
def delete(tenant)
|
|
64
|
+
@http.request("DELETE", self.class.path(tenant))
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A single-use code that enrolls one computer into the Tenant. It expires in 15 minutes.
|
|
69
|
+
# @return [Enrollment]
|
|
70
|
+
def create_enrollment(tenant)
|
|
71
|
+
Enrollment.parse(Parse.obj(@http.json("POST", "#{self.class.path(tenant)}/enrollments")))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Exchange the Organisation Key for a one-hour token that acts as the Tenant. +org_id+
|
|
75
|
+
# defaults to the client's (+SAI_ORG_ID+).
|
|
76
|
+
# @return [TenantToken]
|
|
77
|
+
def token(tenant, org_id: nil)
|
|
78
|
+
org = org_id || @org_id
|
|
79
|
+
raise ArgumentError, "No Organisation id: pass org_id: here or to Sai::Client.new, or set SAI_ORG_ID." if org.nil? || org.empty?
|
|
80
|
+
|
|
81
|
+
path = "/v1/orgs/#{Util.q(org)}/tenants/#{Util.q(Util.id_of(tenant))}/token"
|
|
82
|
+
TenantToken.parse(Parse.obj(@http.json("POST", path)))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A client that acts as the Tenant. It exchanges a token now, and again before a request when
|
|
86
|
+
# the token has less than five minutes left, with this client's Organisation Key.
|
|
87
|
+
# @return [Client]
|
|
88
|
+
def client(tenant, org_id: nil)
|
|
89
|
+
current = token(tenant, org_id: org_id)
|
|
90
|
+
lock = Mutex.new
|
|
91
|
+
@with_credential.call(lambda do
|
|
92
|
+
lock.synchronize do
|
|
93
|
+
current = token(tenant, org_id: org_id) if current.expires_at - Util.now_ms < TOKEN_REFRESH_MARGIN_MS
|
|
94
|
+
current.token
|
|
95
|
+
end
|
|
96
|
+
end)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @api private
|
|
100
|
+
def self.path(tenant)
|
|
101
|
+
"/v1/tenants/#{Util.q(Util.id_of(tenant))}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A Tenant's computers, enrolled and cloud. Every call takes an Organisation Key.
|
|
106
|
+
class TenantMachines
|
|
107
|
+
# Between machine reads while +create(wait: true)+ waits.
|
|
108
|
+
POLL_INTERVAL = 5
|
|
109
|
+
|
|
110
|
+
# @api private
|
|
111
|
+
def initialize(http)
|
|
112
|
+
@http = http
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Create a cloud machine for the Tenant. +os+ is +windows+ or +linux+. +name+ (up to 64
|
|
116
|
+
# characters) is a label you show your users. The same +request_id+ makes a retry safe.
|
|
117
|
+
#
|
|
118
|
+
# Returns at once with +state == "provisioning"+. +wait: true+ reads the machine until it is
|
|
119
|
+
# +ready+; it raises Error (with the machine as +body+) if the machine fails or is deleted,
|
|
120
|
+
# and Timeout::Error after +timeout+ seconds.
|
|
121
|
+
# @return [TenantMachine]
|
|
122
|
+
def create(tenant, os:, name: nil, request_id: nil, wait: false, timeout: 600)
|
|
123
|
+
body = { "os" => os }
|
|
124
|
+
body["name"] = name unless name.nil?
|
|
125
|
+
body["requestId"] = request_id unless request_id.nil?
|
|
126
|
+
machine = TenantMachine.parse(Parse.obj(@http.json("POST", "#{Tenants.path(tenant)}/machines", json: body)))
|
|
127
|
+
return machine unless wait
|
|
128
|
+
|
|
129
|
+
deadline = Util.monotonic + timeout
|
|
130
|
+
while machine.state != "ready"
|
|
131
|
+
if %w[failed deleting].include?(machine.state)
|
|
132
|
+
raise Error.new("Cloud machine #{machine.id} is #{machine.state} and will not become ready.",
|
|
133
|
+
body: machine.raw)
|
|
134
|
+
end
|
|
135
|
+
raise Timeout::Error, "Cloud machine #{machine.id} still #{machine.state}." if Util.monotonic + POLL_INTERVAL > deadline
|
|
136
|
+
|
|
137
|
+
sleep(POLL_INTERVAL)
|
|
138
|
+
begin
|
|
139
|
+
machine = get(tenant, machine.id)
|
|
140
|
+
rescue RateLimitError => e
|
|
141
|
+
sleep(e.retry_after.nil? ? 30 : e.retry_after)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
machine
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Both kinds, enrolled and cloud.
|
|
148
|
+
# @return [Array<TenantMachine>]
|
|
149
|
+
def list(tenant)
|
|
150
|
+
d = Parse.obj(@http.json("GET", "#{Tenants.path(tenant)}/machines"))
|
|
151
|
+
Parse.list(d["machines"]).map { |m| TenantMachine.parse(Parse.obj(m)) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @return [TenantMachine]
|
|
155
|
+
def get(tenant, machine_id)
|
|
156
|
+
TenantMachine.parse(Parse.obj(@http.json("GET", "#{Tenants.path(tenant)}/machines/#{Util.q(machine_id)}")))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# An enrolled machine is signed out at once (+"revoked"+). A cloud machine is deleted with its
|
|
160
|
+
# disk (+"deleting"+), which cannot be undone.
|
|
161
|
+
# @return [String] +"revoked"+ or +"deleting"+
|
|
162
|
+
def delete(tenant, machine)
|
|
163
|
+
res = @http.request("DELETE", "#{Tenants.path(tenant)}/machines/#{Util.q(Util.id_of(machine))}")
|
|
164
|
+
res.status == 202 ? "deleting" : "revoked"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# +delete+ without its answer. On a cloud machine it deletes the machine: use +delete+.
|
|
168
|
+
# @return [nil]
|
|
169
|
+
def revoke(tenant, machine)
|
|
170
|
+
delete(tenant, machine)
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
data/lib/sai/turns.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Sai
|
|
6
|
+
# Turns: what a message to Sai started or joined. Every message gets a turn id (+turn.id+); this
|
|
7
|
+
# reads the turn's record: status, output, evidence, usage, and its trajectory when the message
|
|
8
|
+
# set +record_trajectory+. Each method takes a Turn, a TurnRecord or the id string.
|
|
9
|
+
class Turns
|
|
10
|
+
# Between turn reads; +GET /v1/agents/turns/{id}+ never holds.
|
|
11
|
+
RECORD_POLL_INTERVAL = 2
|
|
12
|
+
|
|
13
|
+
# @api private
|
|
14
|
+
def initialize(http)
|
|
15
|
+
@http = http
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The turn's record now. Never waits: +finalizing+ while the result is worked out.
|
|
19
|
+
# @return [TurnRecord]
|
|
20
|
+
def get(turn)
|
|
21
|
+
TurnRecord.parse(Parse.obj(@http.json("GET", "/v1/agents/turns/#{Util.q(Util.turn_id(turn))}")))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Read the turn until it is +succeeded+, +failed+ or +needs_input+. On +needs_input+, answer
|
|
25
|
+
# the approval on the turn's session (+sai.sessions.resume(record.session_id)+) and wait
|
|
26
|
+
# again. +timeout+ (seconds) raises Timeout::Error with the turn unfinished.
|
|
27
|
+
# @return [TurnRecord]
|
|
28
|
+
def wait(turn, timeout: nil)
|
|
29
|
+
deadline = timeout.nil? ? nil : Util.monotonic + timeout
|
|
30
|
+
self.class.wait_record(@http, Util.turn_id(turn), deadline, %w[succeeded failed needs_input])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The steps the agent took, in order. Recorded only when the message set +record_trajectory+.
|
|
34
|
+
# @return [Array<TrajectoryStep>]
|
|
35
|
+
def trajectory(turn)
|
|
36
|
+
d = Parse.obj(@http.json("GET", "/v1/agents/turns/#{Util.q(Util.turn_id(turn))}/trajectory"))
|
|
37
|
+
Parse.list(d["steps"]).map { |s| TrajectoryStep.parse(Parse.obj(s)) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The screen before step +index+ (the last one is after the turn), as JPEG bytes.
|
|
41
|
+
# @return [String] a binary String
|
|
42
|
+
def screenshot(turn, index)
|
|
43
|
+
@http.request("GET", "/v1/agents/turns/#{Util.q(Util.turn_id(turn))}/trajectory/screenshots/#{index}").body
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @api private
|
|
47
|
+
# Read the turn's record until its status is in +stop+. Raises Timeout::Error past +deadline+
|
|
48
|
+
# (a monotonic time).
|
|
49
|
+
def self.wait_record(http, turn_id, deadline, stop = %w[succeeded failed])
|
|
50
|
+
loop do
|
|
51
|
+
begin
|
|
52
|
+
record = TurnRecord.parse(Parse.obj(http.json("GET", "/v1/agents/turns/#{Util.q(turn_id)}")))
|
|
53
|
+
rescue RateLimitError => e
|
|
54
|
+
sleep(e.retry_after.nil? ? 30 : e.retry_after)
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
return record if stop.include?(record.status)
|
|
58
|
+
if deadline && Util.monotonic + RECORD_POLL_INTERVAL > deadline
|
|
59
|
+
raise Timeout::Error, "Turn #{turn_id} still #{record.status}."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
sleep(RECORD_POLL_INTERVAL)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @api private
|
|
67
|
+
def self.local_result(session_id, text)
|
|
68
|
+
TurnResult.new(status: "idle", text: text, session_id: session_id, approval: nil, error: nil,
|
|
69
|
+
error_code: nil, usage: nil, session_usage: nil, record: nil)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|