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.
@@ -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
@@ -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