pinesandbox 0.0.1 → 0.3.18

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/README.md +668 -4
  4. data/Rakefile +5 -0
  5. data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
  6. data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
  7. data/lib/pinesandbox/agent_events.rb +213 -0
  8. data/lib/pinesandbox/agent_mode.rb +252 -0
  9. data/lib/pinesandbox/agent_turn.rb +75 -0
  10. data/lib/pinesandbox/attach_credentials_source.rb +392 -0
  11. data/lib/pinesandbox/author_terminal_translator.rb +96 -0
  12. data/lib/pinesandbox/bind_hpke.rb +54 -0
  13. data/lib/pinesandbox/capture_keypair.rb +43 -0
  14. data/lib/pinesandbox/client.rb +446 -0
  15. data/lib/pinesandbox/clock.rb +51 -0
  16. data/lib/pinesandbox/computer.rb +1075 -0
  17. data/lib/pinesandbox/computer_binding.rb +47 -0
  18. data/lib/pinesandbox/computer_credentials.rb +175 -0
  19. data/lib/pinesandbox/connection.rb +26 -0
  20. data/lib/pinesandbox/control_events.rb +156 -0
  21. data/lib/pinesandbox/control_plane.rb +341 -0
  22. data/lib/pinesandbox/control_state.rb +31 -0
  23. data/lib/pinesandbox/control_token_source.rb +232 -0
  24. data/lib/pinesandbox/delegated_connection.rb +46 -0
  25. data/lib/pinesandbox/diagnostics.rb +83 -0
  26. data/lib/pinesandbox/drive_mode.rb +67 -0
  27. data/lib/pinesandbox/error_taxonomy.rb +104 -0
  28. data/lib/pinesandbox/errors.rb +525 -0
  29. data/lib/pinesandbox/exec_stream_parser.rb +90 -0
  30. data/lib/pinesandbox/models/artifact.rb +51 -0
  31. data/lib/pinesandbox/models/computer_session.rb +57 -0
  32. data/lib/pinesandbox/models/computer_tab.rb +30 -0
  33. data/lib/pinesandbox/models/file_entry.rb +38 -0
  34. data/lib/pinesandbox/operations.rb +134 -0
  35. data/lib/pinesandbox/response_handler.rb +88 -0
  36. data/lib/pinesandbox/runtime_details.rb +25 -0
  37. data/lib/pinesandbox/runtime_handle.rb +252 -0
  38. data/lib/pinesandbox/session.rb +627 -0
  39. data/lib/pinesandbox/skills.rb +142 -0
  40. data/lib/pinesandbox/spec_version_middleware.rb +34 -0
  41. data/lib/pinesandbox/sse_frame_parser.rb +95 -0
  42. data/lib/pinesandbox/state_hpke.rb +57 -0
  43. data/lib/pinesandbox/version.rb +3 -0
  44. data/lib/pinesandbox/zone.rb +176 -0
  45. data/lib/pinesandbox.rb +40 -2
  46. data/pinesandbox.gemspec +47 -0
  47. metadata +155 -10
@@ -0,0 +1,37 @@
1
+ module PineSandbox
2
+ module Adapters
3
+ # Reads the Computer's most recently saved browser state through
4
+ # `GET /state`, with the Computer token (`ct_`). Returned by
5
+ # {Computer#snapshot}.
6
+ #
7
+ # `latest` returns the raw response body (a JSON Hash): the saved state
8
+ # as an encrypted envelope. Callers that just forward the bytes to
9
+ # external storage can store it as-is; callers that want to decrypt
10
+ # locally can inspect `body["envelope_version"]`. To save the current
11
+ # state first, call {Computer#checkpoint}.
12
+ class SnapshotAdapter
13
+ # @param coordinator [CoordinatorAdapter] the HTTP client wired to
14
+ # this Computer.
15
+ # @param token_provider [Proc] returning the `ct_*` computer token.
16
+ # Lazy so the token is fetched only on first use (the read can
17
+ # come well after sandbox-create when the user actually asks for
18
+ # state).
19
+ def initialize(coordinator:, token_provider:)
20
+ @coordinator = coordinator
21
+ @token_provider = token_provider
22
+ end
23
+
24
+ # Returns the most recently saved state as a JSON Hash, or nil when
25
+ # nothing has been saved yet (a fresh Computer before its first
26
+ # checkpoint).
27
+ #
28
+ # Raises:
29
+ # PineSandbox::ServiceUnavailableError — state persistence is not
30
+ # available on this Computer.
31
+ # PineSandbox::UnauthorizedError — ct_ token rejected.
32
+ def latest
33
+ @coordinator.get_latest_snapshot(token: @token_provider.call)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,213 @@
1
+ require "time"
2
+
3
+ module PineSandbox
4
+ # Immutable typed view of a public agent event's `controller_changed` frame. Unknown
5
+ # trigger strings are preserved so additive server changes remain
6
+ # forward-compatible.
7
+ AgentControlChange = Data.define(
8
+ :controller,
9
+ :mode_epoch,
10
+ :computer_epoch,
11
+ :trigger,
12
+ :changed_at
13
+ )
14
+
15
+ # The agent's caller-facing narration (a `progress` frame).
16
+ AgentProgress = Data.define(:text)
17
+
18
+ # Caller-authored input the coordinator accepted (an `input` frame): the run
19
+ # goal, an answer, or steering. context is run-only. An answer's input
20
+ # opens the turn it belongs to.
21
+ AgentInput = Data.define(:kind, :text, :context)
22
+
23
+ # One step of the agent's work (an `activity` frame). Upsert by id: every
24
+ # step ends with exactly one outcome ("completed", "failed", "declined", or
25
+ # "interrupted" when its turn stopped first), after "running" when the step
26
+ # takes time, and parallel steps interleave. A reader resuming mid-stream may
27
+ # see an outcome without its start. "completed" means the step finished, not
28
+ # that the goal succeeded. kind says what the step was for a person
29
+ # ("browser", "command", "file", "skill", "tool"); the set grows, so treat a
30
+ # kind you don't know as a generic step. title is the Computer's own
31
+ # description of the step in the imperative ("Open Home Page – E-Label",
32
+ # "Click “Log in”", "Edit notes.md", "Use skill book-flight"); state says how
33
+ # it went. Display it as is and do not parse it. facts are what the step
34
+ # acted on (an ActivityFacts), or nil.
35
+ AgentActivity = Data.define(:id, :state, :kind, :title, :facts) do
36
+ def initialize(id:, state:, kind: nil, title: nil, facts: nil) = super
37
+ end
38
+
39
+ # What a step acted on, from the Computer's own record of the work: never a
40
+ # command line, tool argument or anything typed into a page. Every member is
41
+ # optional. steps lists the actions a browser step ran, in order; a failed
42
+ # action is the last one.
43
+ ActivityFacts = Data.define(:page, :target, :files, :skill, :steps) do
44
+ def initialize(page: nil, target: nil, files: [], skill: nil, steps: []) = super
45
+ end
46
+
47
+ # The page a browser step ended on: its title and address (scheme, host, path).
48
+ ActivityPage = Data.define(:title, :url)
49
+ # An element a browser step acted on: its accessibility role and visible name.
50
+ ActivityTarget = Data.define(:role, :name)
51
+ # A work-directory file a step touched, and how ("created", "edited",
52
+ # "deleted", "moved" or "read").
53
+ ActivityFile = Data.define(:path, :change)
54
+ # One of your learned skills a step used.
55
+ ActivitySkill = Data.define(:name)
56
+
57
+ # A picture of the Computer's screen (a `frame` frame), taken after a step
58
+ # that may have changed it. media_type is "image/webp" today; skip a type you
59
+ # can't show. data is the decoded image (a binary String).
60
+ AgentFrame = Data.define(:media_type, :width, :height, :data, :observed_at, :activity) do
61
+ def initialize(media_type:, width:, height:, data:, observed_at: nil, activity: nil) = super
62
+ end
63
+
64
+ # Typed public activity envelope yielded by AgentMode#events. It subclasses Hash so
65
+ # existing callers using event["type"], #dig, or JSON serialization remain
66
+ # compatible while new callers use named accessors and typed payload helpers.
67
+ class AgentEvent < Hash
68
+ CONTROLLERS = %w[agent human locked].freeze
69
+
70
+ class << self
71
+ # Wrap a parsed public activity Hash without changing its string-keyed wire
72
+ # representation. Already-wrapped events are returned as-is.
73
+ def wrap(value)
74
+ return value if value.is_a?(self)
75
+ raise ArgumentError, "agent event must be a Hash" unless value.is_a?(Hash)
76
+
77
+ new.update(value)
78
+ end
79
+ end
80
+
81
+ def schema_version = self["schema_version"]
82
+ def event_id = self["event_id"]
83
+ def task_id = self["task_id"]
84
+ def session = self["session"]
85
+ def computer_id = self["computer_id"]
86
+ # The turn this event belongs to; the same ID run, answer and turn reads
87
+ # return. Absent on older records without a trustworthy turn.
88
+ def turn_id = self["turn_id"]
89
+ def type = self["type"]
90
+ def controller = self["controller"]
91
+ def mode_epoch = self["mode_epoch"]
92
+ def payload = self["payload"]
93
+
94
+ def timestamp
95
+ parse_time(self["ts"])
96
+ end
97
+
98
+ # Return the authoritative typed control transition for a
99
+ # `controller_changed` frame, or nil for another/malformed event. The
100
+ # controller + mode_epoch pair lives on the envelope; commit metadata lives
101
+ # in payload. Requiring the complete shape prevents a partial frame from
102
+ # being mistaken for an authoritative transition.
103
+ def control_change
104
+ return nil unless type == "controller_changed"
105
+ return nil unless CONTROLLERS.include?(controller)
106
+ return nil unless nonnegative_integer?(mode_epoch)
107
+ return nil unless payload.is_a?(Hash)
108
+
109
+ computer_epoch = payload["computer_epoch"]
110
+ trigger = payload["trigger"]
111
+ changed_at = parse_time(payload["changed_at"])
112
+ return nil unless nonnegative_integer?(computer_epoch)
113
+ return nil unless trigger.is_a?(String) && changed_at
114
+
115
+ AgentControlChange.new(
116
+ controller: controller,
117
+ mode_epoch: mode_epoch,
118
+ computer_epoch: computer_epoch,
119
+ trigger: trigger,
120
+ changed_at: changed_at
121
+ )
122
+ end
123
+
124
+ # The narration of a `progress` frame, or nil for another/malformed event.
125
+ def progress
126
+ return nil unless type == "progress" && payload.is_a?(Hash)
127
+
128
+ text = payload["text"]
129
+ text.is_a?(String) && !text.empty? ? AgentProgress.new(text: text) : nil
130
+ end
131
+
132
+ # The accepted caller input of an `input` frame, or nil.
133
+ def input
134
+ return nil unless type == "input" && payload.is_a?(Hash)
135
+ return nil unless payload["kind"].is_a?(String) && payload["text"].is_a?(String)
136
+
137
+ AgentInput.new(kind: payload["kind"], text: payload["text"], context: string_or_nil(payload["context"]))
138
+ end
139
+
140
+ # The step of an `activity` frame, or nil. A malformed optional title is
141
+ # dropped; it never costs the step.
142
+ def activity
143
+ return nil unless type == "activity" && payload.is_a?(Hash)
144
+
145
+ id, state = payload.values_at("activity_id", "state")
146
+ return nil unless [id, state].all? { |value| value.is_a?(String) && !value.empty? }
147
+
148
+ AgentActivity.new(id: id, state: state, kind: string_or_nil(payload["kind"]),
149
+ title: string_or_nil(payload["title"]), facts: activity_facts(payload["facts"]))
150
+ end
151
+
152
+ # The picture of a `frame` frame, or nil for another/malformed event.
153
+ def frame
154
+ return nil unless type == "frame" && payload.is_a?(Hash)
155
+
156
+ media_type, width, height, data = payload.values_at("media_type", "width", "height", "data")
157
+ return nil unless string_or_nil(media_type) && string_or_nil(data)
158
+ return nil unless [width, height].all? { |n| n.is_a?(Integer) && n.positive? }
159
+
160
+ context = AgentEvent.wrap("type" => "activity", "payload" => payload["activity"]).activity
161
+ AgentFrame.new(media_type: media_type, width: width, height: height, data: data.unpack1("m0"),
162
+ observed_at: parse_time(payload["observed_at"]), activity: context)
163
+ rescue ArgumentError
164
+ nil
165
+ end
166
+
167
+ # How the turn ended, including with a question (a `result` frame): the
168
+ # same Hash the turn's "result" holds, or nil for another event.
169
+ def result
170
+ return nil unless type == "result" && payload.is_a?(Hash)
171
+
172
+ payload
173
+ end
174
+
175
+ private
176
+
177
+ # Each fact member is read on its own, so a malformed or future member
178
+ # costs only itself.
179
+ def activity_facts(value)
180
+ return nil unless value.is_a?(Hash)
181
+
182
+ page, target, skill = value.values_at("page", "target", "skill").map { |m| m.is_a?(Hash) ? m : nil }
183
+ page = page && ActivityPage.new(title: string_or_nil(page["title"]), url: string_or_nil(page["url"]))
184
+ target = target && ActivityTarget.new(role: string_or_nil(target["role"]), name: string_or_nil(target["name"]))
185
+ skill = skill && string_or_nil(skill["name"]) && ActivitySkill.new(name: skill["name"])
186
+ files = Array(value["files"]).filter_map do |file|
187
+ next unless file.is_a?(Hash) && string_or_nil(file["path"])
188
+
189
+ ActivityFile.new(path: file["path"], change: string_or_nil(file["change"]))
190
+ end
191
+ steps = Array(value["steps"]).filter_map { |step| string_or_nil(step) }
192
+ return nil if page.nil? && target.nil? && skill.nil? && files.empty? && steps.empty?
193
+
194
+ ActivityFacts.new(page: page, target: target, files: files, skill: skill, steps: steps)
195
+ end
196
+
197
+ def string_or_nil(value)
198
+ value.is_a?(String) && !value.empty? ? value : nil
199
+ end
200
+
201
+ def nonnegative_integer?(value)
202
+ value.is_a?(Integer) && value >= 0
203
+ end
204
+
205
+ def parse_time(value)
206
+ return nil unless value.is_a?(String)
207
+
208
+ Time.iso8601(value)
209
+ rescue ArgumentError
210
+ nil
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,252 @@
1
+ require "securerandom"
2
+ require_relative "agent_events"
3
+ require_relative "agent_turn"
4
+
5
+ module PineSandbox
6
+ # session.agent.* — hand Pine's agent the loop. Work is addressed by turn: a
7
+ # turn starts when a run or an answer is accepted and ends when the agent
8
+ # finishes, fails, is canceled or asks a question. Every ending is the turn's
9
+ # result, with its usage, and an answer is the next turn. Save the turn_id
10
+ # #run and #answer return and #wait for that turn; nothing else needs to be
11
+ # kept between calls or processes. The agent's memory persists across runs
12
+ # on a session. Mutations (run/steer/answer/cancel/reset) use the Computer
13
+ # token (ct_); reads (wait/status/turns/events) use the session token (ps_),
14
+ # or the Computer token for a session handle read without one.
15
+ #
16
+ # Reached via {Session#agent}: `session.agent.run("book a table")`.
17
+ class AgentMode
18
+ # One turn read holds at most this long, inside its HTTP read timeout;
19
+ # #wait repeats reads.
20
+ WAIT_HOLD_SECONDS = 25
21
+ WAITING_STATES = %w[running canceling].freeze
22
+ # A run with its key, an answer and a cancel name the same turn when sent
23
+ # again, so a lost response is resent: a network fault, a request timeout,
24
+ # or a gateway status that cannot say whether the Computer acted. Waits
25
+ # grow from 1 to 5 seconds, never shorter than Retry-After, and stop once
26
+ # the next send would start past the budget, measured from the first send
27
+ # (the transport's own retries inside each send count too), which
28
+ # outlasts a coordinator restart. Steer is never resent.
29
+ RESEND_BUDGET_SECONDS = 30
30
+ LOST_RESPONSE_STATUSES = [408, 502, 503, 504].freeze
31
+
32
+ def initialize(session)
33
+ @session = session
34
+ end
35
+
36
+ # run(goal) — start a turn. Returns the AgentTurn; save its turn_id, then
37
+ # #wait for it. Raises on 409 when a turn is already running (session-busy)
38
+ # or a person holds control. Starting new work leaves an unanswered question
39
+ # unanswered for good.
40
+ #
41
+ # idempotency_key: names this submission, for example your saved message
42
+ # ID. A run with the same key returns the turn that key admitted instead of
43
+ # starting another. Without one, #run uses a key of its own so its resend
44
+ # after a lost response is safe; a process that restarts must pass the key
45
+ # it saved to get the same turn back. When no response arrives within the
46
+ # resend budget, #run raises the last failure — a ConnectionError,
47
+ # TimeoutError or ApiError — extended with RunUnconfirmedError, with
48
+ # #idempotency_key naming the key to send it again with.
49
+ #
50
+ # secrets: optional name→value secrets, delivered to the session secrets
51
+ # file ($SESSION_SECRETS) once the run is admitted and before the agent
52
+ # starts — never model-visible text; the agent references values as $NAME.
53
+ # See the spec's SessionSecrets schema for the naming rules and lifecycle
54
+ # (ephemeral; re-send after re-attach).
55
+ def run(goal, context: nil, skills: nil, constraints: nil, secrets: nil, idempotency_key: nil)
56
+ idempotency_key ||= SecureRandom.uuid_v7
57
+ send_turn do
58
+ coordinator.agent_run(token: ct, name: name, goal: goal,
59
+ context: context, skills: skills, constraints: constraints,
60
+ secrets: secrets, idempotency_key: idempotency_key)
61
+ end
62
+ rescue TimeoutError, ConnectionError, ApiError => e
63
+ if lost_response?(e)
64
+ e.idempotency_key = idempotency_key
65
+ e.extend(RunUnconfirmedError)
66
+ end
67
+ raise
68
+ end
69
+
70
+ # wait(turn_id:, timeout:) — the AgentTurn once it ends: with a question
71
+ # ("needs_input"; read #question), already answered ("answered"; continue
72
+ # with #next_turn_id), or finished ("completed", "failed" or "canceled";
73
+ # read #result). After timeout seconds it returns the turn as it is, still
74
+ # "running" or "canceling"; timeout 0 reads it once. Waiting never cancels
75
+ # the turn or follows its next turn. A read whose response was lost (a
76
+ # network fault, a 408, or a gateway 502, 503 or 504), or whose problem is
77
+ # marked retryable, is read again until the timeout; any other error
78
+ # raises, including a missing or expired turn. A read that comes back
79
+ # sooner than it asked the Computer to hold is followed by a pause of up
80
+ # to a second, so waiting never spins.
81
+ def wait(turn_id:, timeout:)
82
+ deadline = monotonic + timeout
83
+ failures = 0
84
+ loop do
85
+ hold = (deadline - monotonic).clamp(0, WAIT_HOLD_SECONDS).floor
86
+ started = monotonic
87
+ begin
88
+ turn = coordinator.agent_turn(token: read_token, name: name, turn_id: turn_id, wait_seconds: hold)
89
+ rescue TimeoutError, ConnectionError, ApiError => e
90
+ retryable = lost_response?(e) || (e.is_a?(ApiError) && e.retryable? == true)
91
+ raise unless retryable && monotonic < deadline
92
+
93
+ failures += 1
94
+ sleep([retry_delay(failures, e), deadline - monotonic].min)
95
+ next
96
+ end
97
+ failures = 0
98
+ return AgentTurn.wrap(turn) unless WAITING_STATES.include?(turn["state"]) && monotonic < deadline
99
+
100
+ # The Computer holds whole seconds, and may answer before the hold
101
+ # (or ignore it): pause up to a second before reading again.
102
+ sleep([1, deadline - monotonic].min) if hold.zero? || monotonic - started < hold
103
+ end
104
+ end
105
+
106
+ # answer(turn_id:, answer:) — answer the question that ended turn_id.
107
+ # Returns the AgentTurn the answer opened, which continues the same goal;
108
+ # save its turn_id and #wait for it. Only the session's latest turn can be
109
+ # answered. Sending the same answer again returns the same turn without
110
+ # delivering anything again, so a lost response is resent; a different
111
+ # answer to an answered turn raises a 409 (/errors/stale-turn). secrets:
112
+ # delivered once, before the answer's turn is admitted (a failed delivery
113
+ # leaves the question open), so the answer can reference a password or
114
+ # code as $NAME.
115
+ def answer(turn_id:, answer:, secrets: nil)
116
+ send_turn { coordinator.agent_answer(token: ct, name: name, turn_id: turn_id, answer: answer, secrets: secrets) }
117
+ end
118
+
119
+ # steer(turn_id:, text:) — add guidance to turn_id while it runs. Returns
120
+ # the turn. A turn that is not running raises a 409 (/errors/no-active-turn);
121
+ # at a completion boundary that does not prove the text was not delivered,
122
+ # so do not replay it automatically. secrets: delivered only once the
123
+ # steer passes every check.
124
+ def steer(turn_id:, text:, secrets: nil)
125
+ AgentTurn.wrap(coordinator.agent_steer(token: ct, name: name, turn_id: turn_id, text: text, secrets: secrets))
126
+ end
127
+
128
+ # cancel(turn_id:) — cancel turn_id while it runs. Returns the AgentTurn,
129
+ # "canceling" until the agent stops. A turn whose question waits is closed:
130
+ # it ends "canceled" and can no longer be answered. A turn that has ended
131
+ # otherwise is returned unchanged, so a lost response is resent. An
132
+ # answered turn raises a 409 (/errors/stale-turn): cancel the turn its
133
+ # answer opened instead.
134
+ def cancel(turn_id:)
135
+ send_turn { coordinator.agent_cancel(token: ct, name: name, turn_id: turn_id) }
136
+ end
137
+
138
+ # reset — forget the agent's thread so the next run starts on a fresh memory
139
+ # (the recovery / switch-goal tool). Idle-only (409 while a turn is in
140
+ # flight); the browser, files, and history are kept.
141
+ def reset(expected_task_id:)
142
+ AgentTask.wrap(coordinator.agent_reset(token: ct, name: name, expected_task_id: expected_task_id))
143
+ end
144
+
145
+ # status — the session's AgentTask, which scopes event cursors, and its
146
+ # latest #turn. Use it to discover work, not to choose a turn to answer,
147
+ # steer or cancel: those take the turn_id you saved.
148
+ def status(expected_task_id: nil)
149
+ AgentTask.wrap(coordinator.agent_task(token: read_token, name: name, **{ expected_task_id: expected_task_id }.compact))
150
+ end
151
+
152
+ # turns — the retained turns as AgentTurns, oldest first, with the same
153
+ # turn IDs as events and controls.
154
+ def turns(expected_task_id: nil)
155
+ coordinator.agent_turns(token: read_token, name: name, expected_task_id: expected_task_id)
156
+ .fetch("turns").map { |turn| AgentTurn.wrap(turn) }
157
+ end
158
+
159
+ # events — stream the session's public activity feed; yields AgentEvent, a
160
+ # typed Hash-compatible envelope. The feed is continuous across turns: a
161
+ # dropped connection reopens from the last event seen, waiting 0.5 s and
162
+ # doubling up to 5 s, and after five reconnects in a row that delivered
163
+ # nothing it raises StreamLostError. Stop reading by breaking out of the
164
+ # block. Pass last_event_id: to resume from a cursor you saved (with the
165
+ # Task's expected_task_id:). Without a block, returns an Enumerator that
166
+ # opens the stream only when iterated (e.g. `session.agent.events.each {
167
+ # ... }`). Pass frames: false to leave out `frame` events (pictures of the
168
+ # screen) when you show only the steps.
169
+ def events(last_event_id: nil, frames: true, expected_task_id: nil, &block)
170
+ return enum_for(:events, last_event_id: last_event_id, frames: frames, expected_task_id: expected_task_id) unless block
171
+
172
+ coordinator.follow_agent_events(token: read_token, name: name, last_event_id: last_event_id, frames: frames, **{ expected_task_id: expected_task_id }.compact) do |event|
173
+ block.call(AgentEvent.wrap(event))
174
+ end
175
+ end
176
+
177
+ # Cursor-addressed history. Keep expected_task_id with a persisted cursor.
178
+ def events_page(after: nil, limit: nil, expected_task_id: nil, frames: true)
179
+ page = coordinator.agent_events_page(token: read_token, name: name, after: after, limit: limit,
180
+ expected_task_id: expected_task_id, frames: frames)
181
+ page.merge("events" => page.fetch("events").map { |event| AgentEvent.wrap(event) })
182
+ end
183
+
184
+ private
185
+
186
+ # Send a mutation whose repeat names the same turn, resending it after a
187
+ # lost response, and wrap the turn it returns.
188
+ def send_turn
189
+ attempt = 0
190
+ started = monotonic
191
+ begin
192
+ AgentTurn.wrap(yield)
193
+ rescue TimeoutError, ConnectionError, ApiError => e
194
+ raise unless lost_response?(e)
195
+
196
+ attempt += 1
197
+ wait = retry_delay(attempt, e)
198
+ raise if monotonic - started + wait > RESEND_BUDGET_SECONDS
199
+
200
+ sleep(wait)
201
+ retry
202
+ end
203
+ end
204
+
205
+ # The wait before retry attempt after error: growing from 1 to 5 seconds,
206
+ # never shorter than the response's Retry-After (an ApiError's, or a
207
+ # timeout status's).
208
+ def retry_delay(attempt, error)
209
+ retry_after = error.respond_to?(:retry_after) ? error.retry_after.to_i : 0
210
+ [[2**(attempt - 1), 5].min, retry_after].max
211
+ end
212
+
213
+ def lost_response?(error)
214
+ case error
215
+ when TimeoutError, ConnectionError then true
216
+ when ApiError then LOST_RESPONSE_STATUSES.include?(error.status)
217
+ else false
218
+ end
219
+ end
220
+
221
+ def name
222
+ @session.name
223
+ end
224
+
225
+ def coordinator
226
+ @session.send(:coordinator)
227
+ end
228
+
229
+ def ct
230
+ @session.parent.computer_token
231
+ end
232
+
233
+ # The session token, or the Computer token for a handle without one.
234
+ def read_token
235
+ @session.send(:read_token)
236
+ end
237
+
238
+ def monotonic
239
+ clock.monotonic
240
+ end
241
+
242
+ # Every resend and wait goes through the data-plane client's clock.
243
+ def sleep(seconds)
244
+ clock.sleep(seconds)
245
+ end
246
+
247
+ def clock
248
+ adapter = coordinator
249
+ adapter.respond_to?(:clock) ? adapter.clock : PineSandbox::SYSTEM_CLOCK
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,75 @@
1
+ require "time"
2
+
3
+ module PineSandbox
4
+ # One unit of agent work (spec PublicTurn), returned by AgentMode#run,
5
+ # #wait, #answer, #steer and #cancel. A turn starts when a run or an answer is
6
+ # accepted and ends when the agent finishes, fails, is canceled or asks a
7
+ # question. Every ending is the turn's "result", with that turn's usage; a
8
+ # question is a "needs_input" result whose summary is the question, and the
9
+ # answer starts the next turn. "running" and "canceling" change and
10
+ # "needs_input" becomes "answered"; every other state is final.
11
+ #
12
+ # It subclasses Hash so the wire object stays available as is (turn["state"],
13
+ # #dig, JSON); the readers below name its fields.
14
+ class AgentTurn < Hash
15
+ # Wrap a parsed turn Hash without changing its string-keyed wire
16
+ # representation. Already-wrapped turns are returned as is.
17
+ def self.wrap(value)
18
+ return value if value.is_a?(self)
19
+ raise ArgumentError, "agent turn must be a Hash" unless value.is_a?(Hash)
20
+
21
+ new.update(value)
22
+ end
23
+
24
+ def turn_id = self["turn_id"]
25
+ def state = self["state"]
26
+ # The turn this turn's answer opened; present once it is "answered".
27
+ def next_turn_id = self["next_turn_id"]
28
+ # How the turn ended, with "usage"; nil while it runs.
29
+ def result = self["result"]
30
+ def first_event_id = self["first_event_id"]
31
+ def last_event_id = self["last_event_id"]
32
+
33
+ # The question that ended the turn (its result's summary), or nil when the
34
+ # turn did not end with one.
35
+ def question
36
+ ended = result
37
+ ended.is_a?(Hash) && ended["terminal_reason"] == "needs_input" ? ended["summary"] : nil
38
+ end
39
+
40
+ def started_at = parse_time(self["started_at"])
41
+ # nil while the turn is running or canceling.
42
+ def ended_at = parse_time(self["ended_at"])
43
+
44
+ private
45
+
46
+ def parse_time(value)
47
+ return nil unless value.is_a?(String)
48
+
49
+ Time.iso8601(value)
50
+ rescue ArgumentError
51
+ nil
52
+ end
53
+ end
54
+
55
+ # The session's agent Task and its latest turn (spec PublicTask), returned by
56
+ # AgentMode#status and #reset. The Task scopes event cursors; answer, steer
57
+ # and cancel the turn you saved, not whichever turn is latest. A Hash, like
58
+ # AgentTurn.
59
+ class AgentTask < Hash
60
+ def self.wrap(value)
61
+ return value if value.is_a?(self)
62
+ raise ArgumentError, "agent task must be a Hash" unless value.is_a?(Hash)
63
+
64
+ task = new.update(value)
65
+ task["turn"] = AgentTurn.wrap(value["turn"]) if value["turn"].is_a?(Hash)
66
+ task
67
+ end
68
+
69
+ def task_id = self["task_id"]
70
+ def session = self["session"]
71
+ def computer_id = self["computer_id"]
72
+ # The latest turn, an AgentTurn; nil before the first run.
73
+ def turn = self["turn"]
74
+ end
75
+ end