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.
- checksums.yaml +4 -4
- data/Gemfile +3 -0
- data/README.md +668 -4
- data/Rakefile +5 -0
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
- data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
- data/lib/pinesandbox/agent_events.rb +213 -0
- data/lib/pinesandbox/agent_mode.rb +252 -0
- data/lib/pinesandbox/agent_turn.rb +75 -0
- data/lib/pinesandbox/attach_credentials_source.rb +392 -0
- data/lib/pinesandbox/author_terminal_translator.rb +96 -0
- data/lib/pinesandbox/bind_hpke.rb +54 -0
- data/lib/pinesandbox/capture_keypair.rb +43 -0
- data/lib/pinesandbox/client.rb +446 -0
- data/lib/pinesandbox/clock.rb +51 -0
- data/lib/pinesandbox/computer.rb +1075 -0
- data/lib/pinesandbox/computer_binding.rb +47 -0
- data/lib/pinesandbox/computer_credentials.rb +175 -0
- data/lib/pinesandbox/connection.rb +26 -0
- data/lib/pinesandbox/control_events.rb +156 -0
- data/lib/pinesandbox/control_plane.rb +341 -0
- data/lib/pinesandbox/control_state.rb +31 -0
- data/lib/pinesandbox/control_token_source.rb +232 -0
- data/lib/pinesandbox/delegated_connection.rb +46 -0
- data/lib/pinesandbox/diagnostics.rb +83 -0
- data/lib/pinesandbox/drive_mode.rb +67 -0
- data/lib/pinesandbox/error_taxonomy.rb +104 -0
- data/lib/pinesandbox/errors.rb +525 -0
- data/lib/pinesandbox/exec_stream_parser.rb +90 -0
- data/lib/pinesandbox/models/artifact.rb +51 -0
- data/lib/pinesandbox/models/computer_session.rb +57 -0
- data/lib/pinesandbox/models/computer_tab.rb +30 -0
- data/lib/pinesandbox/models/file_entry.rb +38 -0
- data/lib/pinesandbox/operations.rb +134 -0
- data/lib/pinesandbox/response_handler.rb +88 -0
- data/lib/pinesandbox/runtime_details.rb +25 -0
- data/lib/pinesandbox/runtime_handle.rb +252 -0
- data/lib/pinesandbox/session.rb +627 -0
- data/lib/pinesandbox/skills.rb +142 -0
- data/lib/pinesandbox/spec_version_middleware.rb +34 -0
- data/lib/pinesandbox/sse_frame_parser.rb +95 -0
- data/lib/pinesandbox/state_hpke.rb +57 -0
- data/lib/pinesandbox/version.rb +3 -0
- data/lib/pinesandbox/zone.rb +176 -0
- data/lib/pinesandbox.rb +40 -2
- data/pinesandbox.gemspec +47 -0
- 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
|