withruntime 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/withruntime/agents.rb +423 -0
- data/lib/withruntime/client.rb +4 -2
- data/lib/withruntime/products.rb +28 -3
- data/lib/withruntime/sandbox.rb +16 -14
- data/lib/withruntime/version.rb +1 -1
- data/lib/withruntime.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32515cd23a35b91a06324a7af01623f6dd75015c116e041c499e7a9b9548609c
|
|
4
|
+
data.tar.gz: 67454492f534d850643392417477f3f3d2782e7564bebb43555f76c6a1c9adfa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0ce1fea1e0289719efc5d1ece2a48181e8238a400c4abd25d3ed79c12661b6c28465acb33c43681f32dad71428c86a2bd94c11eae6692fbcc2d97e29d31c95c3
|
|
7
|
+
data.tar.gz: 07a2ef09506dd306e8843f1add75482ec75be67e696dcb3b9c66edec9d6b1540208914be83b2f165f89e92f9a54d3c4f27554d435b3ab74c8b5e7f2d67e0d866
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WithRuntime
|
|
4
|
+
# An agent: +id+, +name+, +version+ (one more on each update), +definition+,
|
|
5
|
+
# +labels+, +created_at+, +updated_at+, and +warnings+, such as
|
|
6
|
+
# "subscription_for_customers" for an agent that serves your customers on a
|
|
7
|
+
# subscription login (an empty list when there are none).
|
|
8
|
+
class Agent < Record
|
|
9
|
+
def warnings = self["warnings"] || []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# One entry of a session's log: +seq+, +kind+ (input, answer, reply, ask,
|
|
13
|
+
# status, result, error or note), +actor+, +body+ and +created_at+. +text+
|
|
14
|
+
# is its words, +body.text+.
|
|
15
|
+
class AgentEvent < Record
|
|
16
|
+
def text = self["body"].is_a?(Hash) ? self["body"]["text"] : nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# +runtime.agents+: agents that run in their own sandbox per session, started
|
|
20
|
+
# by your own app through the API, Slack, GitHub, a schedule or a webhook. A private
|
|
21
|
+
# beta: an account outside it is refused +product_in_beta+
|
|
22
|
+
# (PermissionDeniedError).
|
|
23
|
+
#
|
|
24
|
+
# definition = runtime.agents.templates.definition("claude-code", use: "team")
|
|
25
|
+
# runtime.agents.create("reviewer", definition)
|
|
26
|
+
# session = runtime.agents.start("reviewer", "Review the open pull requests.")
|
|
27
|
+
# session.events.each { |event| puts event.text if event.kind == "reply" }
|
|
28
|
+
#
|
|
29
|
+
# A definition is a Hash, by snake_case keyword (+memory_mib:+) or by the
|
|
30
|
+
# API's own key ("memoryMiB"): +command+ (required), +from+ ({ image: } or
|
|
31
|
+
# { snapshot: }), +vcpu+ and +memory_mib+ (4 vCPU and 8 GiB unless set),
|
|
32
|
+
# +instructions+, +env+, +secrets+ (names of egress secrets: model keys go
|
|
33
|
+
# here), +mcp+ ([{ name:, url: }]), +volumes+ ([{ volume:, path: }]),
|
|
34
|
+
# +idle_pause_seconds+, +max_session_seconds+, +max_concurrent+,
|
|
35
|
+
# +max_session_dollars+ (5 unless set: a session at it stops, reason
|
|
36
|
+
# "budget"), +max_day_dollars+ (100 unless set: an agent at it starts nothing
|
|
37
|
+
# new, ConflictError +budget_reached+, until the next day) and +template+
|
|
38
|
+
# ("support" or "team").
|
|
39
|
+
class Agents < Product
|
|
40
|
+
attr_reader :sessions, :triggers, :templates
|
|
41
|
+
|
|
42
|
+
def initialize(transport)
|
|
43
|
+
super
|
|
44
|
+
@sessions = AgentSessions.new(transport)
|
|
45
|
+
@triggers = AgentTriggers.new(transport)
|
|
46
|
+
@templates = AgentTemplates.new
|
|
47
|
+
# Approvals come later, here: agents.approvals.list / answer.
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Makes an agent at version 1. A name already used is a ConflictError
|
|
51
|
+
# (name_taken); the names sessions, triggers, templates and approvals are
|
|
52
|
+
# refused, since they are paths beside the agents.
|
|
53
|
+
def create(name, definition, labels: nil, idempotency_key: nil)
|
|
54
|
+
body = Fields.body(name: name, definition: AgentTemplates.plain(definition), labels: labels)
|
|
55
|
+
Agent.new(@t.json("POST", "/v1/agents", body: body, key: idempotency_key))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# By id or by name.
|
|
59
|
+
def get(agent) = Agent.new(@t.json("GET", "/v1/agents/#{seg(agent)}"))
|
|
60
|
+
|
|
61
|
+
# The first page of agents, at most +limit+ a page; +each+ walks every page.
|
|
62
|
+
def list(cursor: nil, limit: nil) = agents_page(cursor, limit)
|
|
63
|
+
|
|
64
|
+
# Replaces the whole definition: a new version. Sessions already running
|
|
65
|
+
# keep the version they started with.
|
|
66
|
+
def update(agent, definition)
|
|
67
|
+
body = { "definition" => Fields.convert(AgentTemplates.plain(definition)) }
|
|
68
|
+
Agent.new(@t.json("PATCH", "/v1/agents/#{seg(agent)}", body: body))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Deletes an agent: its triggers go and its open sessions are asked to stop.
|
|
72
|
+
def delete(agent) = @t.json("DELETE", "/v1/agents/#{seg(agent)}") && nil
|
|
73
|
+
|
|
74
|
+
# Starts a session with +input+, its first words. With +key:+, your own
|
|
75
|
+
# name for the conversation (a Slack thread, a pull request, one of your
|
|
76
|
+
# customers such as "customer:42"), words with the same key join the open
|
|
77
|
+
# session instead of starting a second; +session.joined+ says so. An agent
|
|
78
|
+
# at its day's budget is refused +budget_reached+ (ConflictError).
|
|
79
|
+
def start(agent, input, key: nil, labels: nil, idempotency_key: nil)
|
|
80
|
+
body = Fields.body(input: input, key: key, labels: labels)
|
|
81
|
+
answer = @t.json("POST", "/v1/agents/#{seg(agent)}/sessions", body: body, key: idempotency_key)
|
|
82
|
+
AgentSession.new(@t, answer["session"], joined: answer["joined"] == true, seq: answer["seq"])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def agents_page(cursor, limit)
|
|
88
|
+
body = @t.json("GET", "/v1/agents", query: { "cursor" => cursor, "limit" => limit })
|
|
89
|
+
Page.new(body["data"].map { |item| Agent.new(item) }, body["nextCursor"]) { |after| agents_page(after, limit) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# +runtime.agents.sessions+: find sessions. A session is one piece of work
|
|
94
|
+
# or one conversation; it has its own sandbox while it runs.
|
|
95
|
+
class AgentSessions < Product
|
|
96
|
+
# A session to follow, send to, stop or wait for.
|
|
97
|
+
def get(id)
|
|
98
|
+
info = @t.json("GET", "/v1/agents/sessions/#{seg(id)}")
|
|
99
|
+
AgentSession.new(@t, info, joined: false, seq: info["lastSeq"])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Deletes an ended session and its history; until then it is kept,
|
|
103
|
+
# counted as storage. An open session is refused +session_open+
|
|
104
|
+
# (ConflictError): stop it first.
|
|
105
|
+
def delete(id) = @t.json("DELETE", "/v1/agents/sessions/#{seg(id)}") && nil
|
|
106
|
+
|
|
107
|
+
# The first page of sessions, as records; +each+ walks every page.
|
|
108
|
+
# +agent+ is an id or a name; +state+ is queued, running, waiting, done,
|
|
109
|
+
# failed or stopped; at most +limit+ a page.
|
|
110
|
+
def list(agent: nil, state: nil, cursor: nil, limit: nil)
|
|
111
|
+
sessions_page({ "agent" => agent, "state" => state, "limit" => limit }, cursor)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def sessions_page(query, cursor)
|
|
117
|
+
body = @t.json("GET", "/v1/agents/sessions", query: query.merge("cursor" => cursor))
|
|
118
|
+
Page.new(body["data"].map { |item| Record.new(item) }, body["nextCursor"]) { |after| sessions_page(query, after) }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A session of an agent. +info+ is its latest record: +state+ (queued,
|
|
123
|
+
# running, waiting, done, failed or stopped), +reason+, +result+,
|
|
124
|
+
# +last_seq+ and the rest; once the API sends them, +parent_session_id+ and
|
|
125
|
+
# +root_session_id+ (a child session's parent and its tree's root), +cost+
|
|
126
|
+
# ({ dollars, vcpuSeconds, gibSeconds } so far), +tree_cost+ (its children's
|
|
127
|
+
# summed in), +history_bytes+ (text history, counted as storage) and
|
|
128
|
+
# +attachment_bytes+ (files and screenshots, counted as storage), each nil
|
|
129
|
+
# until then; history is kept until the session is deleted
|
|
130
|
+
# (+sessions.delete+). +joined+ is whether +start+
|
|
131
|
+
# joined an open session with the same key, and +seq+ the number of the
|
|
132
|
+
# words it sent.
|
|
133
|
+
class AgentSession
|
|
134
|
+
ENDED = %w[done failed stopped].freeze
|
|
135
|
+
# How long the API holds an events read open, and the margin past it a
|
|
136
|
+
# read waits for its answer.
|
|
137
|
+
POLL_SECONDS = 30
|
|
138
|
+
POLL_MARGIN = 15
|
|
139
|
+
|
|
140
|
+
attr_reader :info, :joined, :seq
|
|
141
|
+
|
|
142
|
+
def initialize(transport, info, joined: false, seq: nil)
|
|
143
|
+
@t = transport
|
|
144
|
+
@info = Record.new(info)
|
|
145
|
+
@joined = joined
|
|
146
|
+
@seq = seq
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def id = @info["id"]
|
|
150
|
+
def state = @info["state"]
|
|
151
|
+
def joined? = @joined
|
|
152
|
+
def ended? = ENDED.include?(state)
|
|
153
|
+
|
|
154
|
+
# Reads the session again.
|
|
155
|
+
def refresh(timeout: nil)
|
|
156
|
+
@info = Record.new(@t.json("GET", path, timeout: timeout))
|
|
157
|
+
self
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Sends words into a running or waiting session and answers their seq.
|
|
161
|
+
# The idempotency key, made here when not given, means a retried send is
|
|
162
|
+
# never two messages. Named send_message, not send, so Ruby's own
|
|
163
|
+
# Object#send stays as it is.
|
|
164
|
+
def send_message(text, idempotency_key: nil)
|
|
165
|
+
@t.json("POST", path("/messages"), body: { "text" => text }, key: idempotency_key)["seq"]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The session's log after +after+ (0, the whole log), in order, each an
|
|
169
|
+
# AgentEvent; without a block, an Enumerator. It waits for new events and
|
|
170
|
+
# ends once the session has ended and its last event has been given.
|
|
171
|
+
def events(after: 0, &block)
|
|
172
|
+
return enum_for(:events, after: after) unless block
|
|
173
|
+
|
|
174
|
+
wait = POLL_SECONDS
|
|
175
|
+
loop do
|
|
176
|
+
page = read_events(after, wait)
|
|
177
|
+
data = page["data"] || []
|
|
178
|
+
data.each do |item|
|
|
179
|
+
event = AgentEvent.new(item)
|
|
180
|
+
after = event.seq
|
|
181
|
+
yield event
|
|
182
|
+
end
|
|
183
|
+
# Ended: read on at once, without waiting, until nothing is left.
|
|
184
|
+
if page["ended"]
|
|
185
|
+
return nil if wait.zero? && data.empty?
|
|
186
|
+
|
|
187
|
+
wait = 0
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Asks the session to stop and keeps the answer. A running session ends
|
|
193
|
+
# once its sandbox is deleted; this does not wait for that (+wait+ does).
|
|
194
|
+
def stop
|
|
195
|
+
@info = Record.new(@t.json("POST", path("/stop"), body: {}))
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The session once it has ended (done, failed or stopped); a failed
|
|
199
|
+
# session is returned, not raised. Waits on the server, with no polling.
|
|
200
|
+
# +timeout+ in seconds (nil or 0: no deadline) raises a ConnectionError
|
|
201
|
+
# with code "timeout" when it passes first.
|
|
202
|
+
def wait(timeout: nil)
|
|
203
|
+
deadline = timeout&.positive? ? now + timeout : nil
|
|
204
|
+
loop do
|
|
205
|
+
refresh(timeout: deadline && left(deadline))
|
|
206
|
+
return @info if ended?
|
|
207
|
+
|
|
208
|
+
loop do
|
|
209
|
+
seconds = deadline ? [POLL_SECONDS, left(deadline).ceil].min : POLL_SECONDS
|
|
210
|
+
page = read_events(@info["lastSeq"] || 0, seconds)
|
|
211
|
+
break if page["ended"] || !(page["data"] || []).empty?
|
|
212
|
+
|
|
213
|
+
left(deadline) if deadline
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def inspect = "#<#{self.class.name} #{id} #{state}>"
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
def path(suffix = "") = "/v1/agents/sessions/#{Transport.segment(id)}#{suffix}"
|
|
223
|
+
|
|
224
|
+
# One long poll: its own deadline past the API's hold, whatever the client's.
|
|
225
|
+
def read_events(after, wait)
|
|
226
|
+
@t.json("GET", path("/events"), query: { "after" => after, "wait" => wait }, timeout: [@t.timeout, wait + POLL_MARGIN].max)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
230
|
+
|
|
231
|
+
def left(deadline)
|
|
232
|
+
seconds = deadline - now
|
|
233
|
+
raise @t.connection_error(true, false, nil) unless seconds.positive?
|
|
234
|
+
|
|
235
|
+
seconds
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# +runtime.agents.triggers+: what starts or feeds an agent's sessions
|
|
240
|
+
# besides the API. A trigger is +id+, +agent_id+, +kind+, +config+,
|
|
241
|
+
# +paused_at+, +paused_why+ and +created_at+. The kinds and their configs:
|
|
242
|
+
#
|
|
243
|
+
# slack { team: "T…", channel: "C…" }, once Runtime's Slack app is installed
|
|
244
|
+
# github { repository: "owner/name", installation: <Runtime's GitHub App
|
|
245
|
+
# installation id, the organization's own>, events: [...] (optional) }
|
|
246
|
+
# schedule { cron: "0 9 * * 1-5", timezone: "America/Denver" (optional), input: "words" }
|
|
247
|
+
# webhook {}
|
|
248
|
+
#
|
|
249
|
+
# A slack or github trigger whose app is not installed for the account is
|
|
250
|
+
# refused +not_installed+ (ConflictError).
|
|
251
|
+
class AgentTriggers < Product
|
|
252
|
+
def create(agent, kind, config = {})
|
|
253
|
+
Record.new(@t.json("POST", "/v1/agents/#{seg(agent)}/triggers", body: { "kind" => kind, "config" => Fields.convert(config.to_h) }))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def list(agent) = @t.json("GET", "/v1/agents/#{seg(agent)}/triggers")["data"].map { |row| Record.new(row) }
|
|
257
|
+
|
|
258
|
+
# Holds this trigger alone; its events are refused +trigger_paused+, with
|
|
259
|
+
# +why+, where they came from. Nothing else is held.
|
|
260
|
+
def pause(id, why) = Record.new(@t.json("POST", "/v1/agents/triggers/#{seg(id)}/pause", body: { "why" => why }))
|
|
261
|
+
|
|
262
|
+
def resume(id) = Record.new(@t.json("POST", "/v1/agents/triggers/#{seg(id)}/resume", body: {}))
|
|
263
|
+
|
|
264
|
+
def delete(id) = @t.json("DELETE", "/v1/agents/triggers/#{seg(id)}") && nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# +runtime.agents.templates+: the five harnesses an agent can run, each
|
|
268
|
+
# usable for either use, and the two uses.
|
|
269
|
+
#
|
|
270
|
+
# runtime.agents.templates.map(&:key) # claude-code, codex, gemini-cli, claude-agent-sdk, openai-agents-sdk
|
|
271
|
+
# runtime.agents.templates["codex"].api_key_secret
|
|
272
|
+
# runtime.agents.templates.uses["support"].session_key
|
|
273
|
+
# runtime.agents.templates.definition("claude-code", use: "support")
|
|
274
|
+
#
|
|
275
|
+
# A subscription is for your own team's agents. An agent that serves your
|
|
276
|
+
# customers (api or webhook sessions for end users) uses an API key: those
|
|
277
|
+
# are the providers' terms, and the API warns +subscription_for_customers+
|
|
278
|
+
# on an agent that does not.
|
|
279
|
+
class AgentTemplates
|
|
280
|
+
include Enumerable
|
|
281
|
+
|
|
282
|
+
# The same values as every Runtime SDK and the guide
|
|
283
|
+
# (packages/cloud-guide/agent-templates.json).
|
|
284
|
+
DATA = {
|
|
285
|
+
"templates" => {
|
|
286
|
+
"claude-code" => {
|
|
287
|
+
"key" => "claude-code",
|
|
288
|
+
"title" => "Claude Code",
|
|
289
|
+
"command" => "npx -y @anthropic-ai/claude-code --dangerously-skip-permissions --mcp-config ~/.runtime-agent/mcp.json -p \"$(cat ~/.runtime-agent/start.md)\"",
|
|
290
|
+
"apiKeySecret" => "ANTHROPIC_API_KEY",
|
|
291
|
+
"apiKeyHost" => "api.anthropic.com",
|
|
292
|
+
"subscription" => "token: run `claude setup-token` on your own computer (Pro/Max), then `runtime secrets set CLAUDE_CODE_OAUTH_TOKEN` scoped to api.anthropic.com",
|
|
293
|
+
"definition" => {
|
|
294
|
+
"command" => "npx -y @anthropic-ai/claude-code --dangerously-skip-permissions --mcp-config ~/.runtime-agent/mcp.json -p \"$(cat ~/.runtime-agent/start.md)\"",
|
|
295
|
+
"secrets" => ["ANTHROPIC_API_KEY"]
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
"codex" => {
|
|
299
|
+
"key" => "codex",
|
|
300
|
+
"title" => "Codex CLI",
|
|
301
|
+
"command" => "npx -y @openai/codex exec --dangerously-bypass-approvals-and-sandbox \"$(cat ~/.runtime-agent/start.md)\"",
|
|
302
|
+
"apiKeySecret" => "OPENAI_API_KEY",
|
|
303
|
+
"apiKeyHost" => "api.openai.com",
|
|
304
|
+
"subscription" => "login once in the sandbox's terminal: `npx -y @openai/codex login --device-auth`, kept in the snapshot or a volume at `~/.codex`",
|
|
305
|
+
"definition" => {
|
|
306
|
+
"command" => "npx -y @openai/codex exec --dangerously-bypass-approvals-and-sandbox \"$(cat ~/.runtime-agent/start.md)\"",
|
|
307
|
+
"secrets" => ["OPENAI_API_KEY"]
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
"gemini-cli" => {
|
|
311
|
+
"key" => "gemini-cli",
|
|
312
|
+
"title" => "Gemini CLI",
|
|
313
|
+
"command" => "npx -y @google/gemini-cli --yolo -p \"$(cat ~/.runtime-agent/start.md)\"",
|
|
314
|
+
"apiKeySecret" => "GEMINI_API_KEY",
|
|
315
|
+
"apiKeyHost" => "generativelanguage.googleapis.com",
|
|
316
|
+
"subscription" => "login once in the sandbox's terminal (Sign in with Google), kept at `~/.gemini`",
|
|
317
|
+
"definition" => {
|
|
318
|
+
"command" => "npx -y @google/gemini-cli --yolo -p \"$(cat ~/.runtime-agent/start.md)\"",
|
|
319
|
+
"secrets" => ["GEMINI_API_KEY"]
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
"claude-agent-sdk" => {
|
|
323
|
+
"key" => "claude-agent-sdk",
|
|
324
|
+
"title" => "Claude Agent SDK",
|
|
325
|
+
"command" => "python3 ~/agent.py",
|
|
326
|
+
"apiKeySecret" => "ANTHROPIC_API_KEY",
|
|
327
|
+
"apiKeyHost" => "api.anthropic.com",
|
|
328
|
+
"subscription" => nil,
|
|
329
|
+
"definition" => {
|
|
330
|
+
"command" => "python3 ~/agent.py",
|
|
331
|
+
"secrets" => ["ANTHROPIC_API_KEY"]
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
"openai-agents-sdk" => {
|
|
335
|
+
"key" => "openai-agents-sdk",
|
|
336
|
+
"title" => "OpenAI Agents SDK",
|
|
337
|
+
"command" => "python3 ~/agent.py",
|
|
338
|
+
"apiKeySecret" => "OPENAI_API_KEY",
|
|
339
|
+
"apiKeyHost" => "api.openai.com",
|
|
340
|
+
"subscription" => nil,
|
|
341
|
+
"definition" => {
|
|
342
|
+
"command" => "python3 ~/agent.py",
|
|
343
|
+
"secrets" => ["OPENAI_API_KEY"]
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
"uses" => {
|
|
348
|
+
"support" => {
|
|
349
|
+
"key" => "support",
|
|
350
|
+
"title" => "Your customers",
|
|
351
|
+
"sessionKey" => "customer:<id>",
|
|
352
|
+
"triggers" => %w[api webhook],
|
|
353
|
+
"definition" => {
|
|
354
|
+
"template" => "support",
|
|
355
|
+
"vcpu" => 1,
|
|
356
|
+
"memoryMiB" => 1024,
|
|
357
|
+
"idlePauseSeconds" => 10,
|
|
358
|
+
"maxConcurrent" => 1000
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
"team" => {
|
|
362
|
+
"key" => "team",
|
|
363
|
+
"title" => "Your team",
|
|
364
|
+
"sessionKey" => nil,
|
|
365
|
+
"triggers" => %w[slack github],
|
|
366
|
+
"definition" => {
|
|
367
|
+
"template" => "team",
|
|
368
|
+
"vcpu" => 4,
|
|
369
|
+
"memoryMiB" => 8192
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}.then { |data| JSON.parse(JSON.generate(data), freeze: true) }
|
|
374
|
+
|
|
375
|
+
# A definition given as a Record (a template's own) as the Hash it holds.
|
|
376
|
+
def self.plain(definition) = definition.is_a?(Record) ? definition.to_h : definition
|
|
377
|
+
|
|
378
|
+
# The harness template +key+ (+key+, +title+, +command+, +api_key_secret+,
|
|
379
|
+
# +api_key_host+, +subscription+: how to log in on one, or nil, and
|
|
380
|
+
# +definition+), or nil.
|
|
381
|
+
def [](key) = DATA["templates"][key.to_s]&.then { |template| Record.new(template) }
|
|
382
|
+
|
|
383
|
+
def keys = DATA["templates"].keys
|
|
384
|
+
|
|
385
|
+
# The harness templates, in order.
|
|
386
|
+
def each(&block)
|
|
387
|
+
return enum_for(:each) unless block
|
|
388
|
+
|
|
389
|
+
DATA["templates"].each_value { |template| block.call(Record.new(template)) }
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# The uses, "support" (your customers, driven by your own app: one session
|
|
393
|
+
# per end customer under your own key such as "customer:42", paused between
|
|
394
|
+
# their messages) and "team" (your team's
|
|
395
|
+
# code, from Slack and GitHub): +key+, +title+, +session_key+, +triggers+
|
|
396
|
+
# and the +definition+ preset merged over a template's.
|
|
397
|
+
def uses = DATA["uses"].transform_values { |use| Record.new(use) }
|
|
398
|
+
|
|
399
|
+
# A new definition: the template's, with the use's preset over it. Ready
|
|
400
|
+
# for +agents.create(name, definition)+. An unknown key raises
|
|
401
|
+
# InvalidRequestError before anything is sent.
|
|
402
|
+
def definition(template, use: nil)
|
|
403
|
+
found = DATA["templates"][template.to_s] or raise unknown("template", template, DATA["templates"].keys)
|
|
404
|
+
out = JSON.parse(JSON.generate(found["definition"]))
|
|
405
|
+
return out if use.nil?
|
|
406
|
+
|
|
407
|
+
preset = DATA["uses"][use.to_s] or raise unknown("use", use, DATA["uses"].keys)
|
|
408
|
+
out.merge(JSON.parse(JSON.generate(preset["definition"])))
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Every template and use as plain JSON data: { "templates" => ..., "uses" => ... }.
|
|
412
|
+
def to_h = JSON.parse(JSON.generate(DATA))
|
|
413
|
+
|
|
414
|
+
def inspect = "#<#{self.class.name} #{keys.join(", ")}; uses #{DATA["uses"].keys.join(", ")}>"
|
|
415
|
+
|
|
416
|
+
private
|
|
417
|
+
|
|
418
|
+
def unknown(what, value, known)
|
|
419
|
+
InvalidRequestError.new("No agent #{what} is called #{value.inspect}.", code: "invalid_request", status: 400,
|
|
420
|
+
hint: "One of: #{known.join(", ")}.")
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
data/lib/withruntime/client.rb
CHANGED
|
@@ -23,8 +23,8 @@ module WithRuntime
|
|
|
23
23
|
RENAME_RETIRES = nil
|
|
24
24
|
|
|
25
25
|
class Client
|
|
26
|
-
attr_reader :transport, :sandboxes, :snapshots, :images, :volumes, :secrets, :limits, :referrals,
|
|
27
|
-
:switching, :events, :webhooks, :otel, :audit, :feedback, :support, :domains, :ports, :ips, :networks, :sso, :mcp
|
|
26
|
+
attr_reader :transport, :sandboxes, :snapshots, :images, :volumes, :secrets, :limits, :emails, :referrals,
|
|
27
|
+
:switching, :events, :webhooks, :otel, :audit, :feedback, :support, :domains, :ports, :ips, :networks, :sso, :mcp, :agents
|
|
28
28
|
|
|
29
29
|
# +api_key+: default RUNTIME_API_KEY, then this machine's saved connection.
|
|
30
30
|
# +base_url+: default RUNTIME_API_URL, then https://api.withruntime.com.
|
|
@@ -56,6 +56,7 @@ module WithRuntime
|
|
|
56
56
|
@volumes = Volumes.new(@transport)
|
|
57
57
|
@secrets = Secrets.new(@transport)
|
|
58
58
|
@limits = Limits.new(@transport)
|
|
59
|
+
@emails = Emails.new(@transport)
|
|
59
60
|
@referrals = Referrals.new(@transport)
|
|
60
61
|
@switching = Switching.new(@transport)
|
|
61
62
|
@events = Events.new(@transport)
|
|
@@ -70,6 +71,7 @@ module WithRuntime
|
|
|
70
71
|
@networks = Networks.new(PrivateTunnel.new(@transport))
|
|
71
72
|
@sso = SSO.new(@transport)
|
|
72
73
|
@mcp = MCP.new(@transport)
|
|
74
|
+
@agents = Agents.new(@transport)
|
|
73
75
|
end
|
|
74
76
|
|
|
75
77
|
# Renamed on 6 October 2026, one plural word per product: each old name is the product it became, with a warning,
|
data/lib/withruntime/products.rb
CHANGED
|
@@ -88,12 +88,22 @@ module WithRuntime
|
|
|
88
88
|
class Secrets < Product
|
|
89
89
|
# Stores or replaces a secret for +hosts+ ("api.openai.com", "*.github.com").
|
|
90
90
|
# With +header:+, that header is set on every request to them, +format:+'s
|
|
91
|
-
# {value} replaced.
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
# {value} replaced. With +only:+ (<tt>{ labels: [{ role: "hub" }], sandboxes: [id] }</tt>),
|
|
92
|
+
# only those sandboxes get it instead of every sandbox of the organization: those whose
|
|
93
|
+
# labels hold every pair of any one selector, and those named by id. Left out when replacing
|
|
94
|
+
# a secret, the scope it has stays, so a new value never widens who gets it; +{}+ is every
|
|
95
|
+
# sandbox again.
|
|
96
|
+
def set(name, value:, hosts:, header: nil, format: nil, only: nil)
|
|
97
|
+
body = { "value" => value, "hosts" => hosts, "header" => header, "format" => format, "only" => only }.compact
|
|
94
98
|
Record.new(@t.json("PUT", "/v1/egress-secrets/#{seg(name)}", body: body))
|
|
95
99
|
end
|
|
96
100
|
|
|
101
|
+
# Changes which sandboxes get a secret, without its value: +{}+ is every sandbox of the
|
|
102
|
+
# organization again. Returns the secret, with +only+ and +enforced+.
|
|
103
|
+
def scope(name, only)
|
|
104
|
+
Record.new(@t.json("PUT", "/v1/egress-secrets/#{seg(name)}/sandboxes", body: only))
|
|
105
|
+
end
|
|
106
|
+
|
|
97
107
|
# Names, hosts and placeholders. Never values.
|
|
98
108
|
def list = @t.json("GET", "/v1/egress-secrets")["secrets"].map { |secret| Record.new(secret) }
|
|
99
109
|
|
|
@@ -115,6 +125,21 @@ module WithRuntime
|
|
|
115
125
|
def get = Record.new(@t.json("GET", "/v1/referrals"))
|
|
116
126
|
end
|
|
117
127
|
|
|
128
|
+
# +runtime.emails+: which of Runtime's optional emails this key's person gets
|
|
129
|
+
# for this account: own-deletions (warnings for dates the account's own
|
|
130
|
+
# retention set), billing, usage and tips. Sign-in, support replies, credit
|
|
131
|
+
# expiry and deletions Runtime decides are always sent and are not listed.
|
|
132
|
+
class Emails < Product
|
|
133
|
+
def get = @t.json("GET", "/v1/account/emails")["settings"].map { |row| Record.new(row) }
|
|
134
|
+
|
|
135
|
+
# Turns one on or off for this key's person only, and answers all of them.
|
|
136
|
+
# Needs a key for every action.
|
|
137
|
+
def set(category, enabled)
|
|
138
|
+
body = { "category" => category, "enabled" => enabled }
|
|
139
|
+
@t.json("POST", "/v1/account/emails:update", body: body)["settings"].map { |row| Record.new(row) }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
118
143
|
# +runtime.switching+: compare usage with a rival's published rates, and record a switch.
|
|
119
144
|
class Switching < Product
|
|
120
145
|
# Your settled usage over the last +days+ (30 by default, at most 90) priced
|
data/lib/withruntime/sandbox.rb
CHANGED
|
@@ -241,19 +241,20 @@ module WithRuntime
|
|
|
241
241
|
# Runs a command and returns its exit code and output. A String runs under
|
|
242
242
|
# +bash -c+; an Array runs the program directly, with no shell.
|
|
243
243
|
#
|
|
244
|
-
# +cwd+ (default /workspace), +
|
|
245
|
-
#
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
|
|
244
|
+
# +cwd+ (default /workspace), +user+ ("sandbox", the default, or "root",
|
|
245
|
+
# through the sandbox's passwordless sudo), +env+ (merged over the
|
|
246
|
+
# sandbox's; put secrets here), +stdin+, +timeout+ (seconds, default 60, up
|
|
247
|
+
# to a day; a timeout is a result, not an error), +on_stdout+ and
|
|
248
|
+
# +on_stderr+ (stream the command; the result keeps everything), and
|
|
249
|
+
# +check+ (raise CommandError on a non-zero exit or a timeout).
|
|
250
|
+
def exec(command, cwd: nil, user: nil, env: nil, stdin: nil, timeout: nil, on_stdout: nil, on_stderr: nil,
|
|
250
251
|
check: false, idempotency_key: nil)
|
|
251
252
|
streamed = on_stdout || on_stderr || (timeout && timeout > 60)
|
|
252
253
|
result = if streamed
|
|
253
|
-
collect(exec_stream(command, cwd: cwd, env: env, stdin: stdin, timeout: timeout,
|
|
254
|
+
collect(exec_stream(command, cwd: cwd, user: user, env: env, stdin: stdin, timeout: timeout,
|
|
254
255
|
idempotency_key: idempotency_key), on_stdout, on_stderr)
|
|
255
256
|
else
|
|
256
|
-
body = command_body(command, cwd, env, stdin, timeout)
|
|
257
|
+
body = command_body(command, cwd, env, stdin, timeout, user)
|
|
257
258
|
CommandResult.new(@t.json("POST", path(":exec"), body: body, key: idempotency_key,
|
|
258
259
|
timeout: timeout && (timeout + 60)))
|
|
259
260
|
end
|
|
@@ -265,14 +266,14 @@ module WithRuntime
|
|
|
265
266
|
# Runs a command and yields its events as they happen: start, stdout,
|
|
266
267
|
# stderr, exit (each a Hash). Resumes by itself when the server ends a long
|
|
267
268
|
# stream, so it never drops output. Without a block, an Enumerator.
|
|
268
|
-
def exec_stream(command, cwd: nil, env: nil, stdin: nil, timeout: nil, idempotency_key: nil, &block)
|
|
269
|
+
def exec_stream(command, cwd: nil, user: nil, env: nil, stdin: nil, timeout: nil, idempotency_key: nil, &block)
|
|
269
270
|
unless block
|
|
270
|
-
return enum_for(:exec_stream, command, cwd: cwd, env: env, stdin: stdin, timeout: timeout,
|
|
271
|
+
return enum_for(:exec_stream, command, cwd: cwd, user: user, env: env, stdin: stdin, timeout: timeout,
|
|
271
272
|
idempotency_key: idempotency_key)
|
|
272
273
|
end
|
|
273
274
|
|
|
274
275
|
longest = ApiDefaults::STREAMED_EXEC_TIMEOUT_SECONDS
|
|
275
|
-
body = command_body(command, cwd, env, stdin, timeout || longest).merge("stream" => true)
|
|
276
|
+
body = command_body(command, cwd, env, stdin, timeout || longest, user).merge("stream" => true)
|
|
276
277
|
handed_over = nil
|
|
277
278
|
process_id = nil
|
|
278
279
|
cursor = 0
|
|
@@ -361,8 +362,8 @@ module WithRuntime
|
|
|
361
362
|
# once. It outlives your connection; +process(id)+ gets it back.
|
|
362
363
|
# +pipe_stdin: true+ keeps input open for +write+; +pty: { cols:, rows: }+
|
|
363
364
|
# gives it a terminal.
|
|
364
|
-
def spawn(command, cwd: nil, env: nil, timeout: nil, stdin: nil, pipe_stdin: false, pty: nil, idempotency_key: nil)
|
|
365
|
-
body = command_body(command, cwd, env, pipe_stdin ? nil : stdin, timeout)
|
|
365
|
+
def spawn(command, cwd: nil, user: nil, env: nil, timeout: nil, stdin: nil, pipe_stdin: false, pty: nil, idempotency_key: nil)
|
|
366
|
+
body = command_body(command, cwd, env, pipe_stdin ? nil : stdin, timeout, user)
|
|
366
367
|
body["stdinMode"] = "pipe" if pipe_stdin
|
|
367
368
|
body["pty"] = Fields.body(pty) if pty
|
|
368
369
|
SandboxProcess.new(self, @t.json("POST", path("/processes"), body: body, key: idempotency_key))
|
|
@@ -385,9 +386,10 @@ module WithRuntime
|
|
|
385
386
|
|
|
386
387
|
def room_for(wait_for_capacity) = wait_for_capacity.nil? ? @t.wait_for_capacity : [0, wait_for_capacity].max
|
|
387
388
|
|
|
388
|
-
def command_body(command, cwd, env, stdin, timeout)
|
|
389
|
+
def command_body(command, cwd, env, stdin, timeout, user)
|
|
389
390
|
body = command.is_a?(Array) ? { "argv" => command.map(&:to_s) } : { "command" => command.to_s }
|
|
390
391
|
body["cwd"] = cwd if cwd
|
|
392
|
+
body["user"] = user.to_s if user
|
|
391
393
|
body["env"] = env.to_h { |key, value| [key.to_s, value.to_s] } if env
|
|
392
394
|
body["stdinBase64"] = [stdin.to_s.b].pack("m0") unless stdin.nil?
|
|
393
395
|
body["timeoutMs"] = (timeout * 1000).round if timeout
|
data/lib/withruntime/version.rb
CHANGED
data/lib/withruntime.rb
CHANGED
|
@@ -26,6 +26,7 @@ require_relative "withruntime/wallet"
|
|
|
26
26
|
require_relative "withruntime/tar"
|
|
27
27
|
require_relative "withruntime/sandbox"
|
|
28
28
|
require_relative "withruntime/products"
|
|
29
|
+
require_relative "withruntime/agents"
|
|
29
30
|
require_relative "withruntime/images"
|
|
30
31
|
require_relative "withruntime/network_products"
|
|
31
32
|
require_relative "withruntime/parity_products"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: withruntime
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Runtime
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: 'Linux sandboxes for agents, and every other Runtime Cloud product: streaming
|
|
14
14
|
commands and terminals, files and watches, images, durable volumes and backups,
|
|
@@ -20,6 +20,7 @@ extra_rdoc_files: []
|
|
|
20
20
|
files:
|
|
21
21
|
- LICENSE
|
|
22
22
|
- lib/withruntime.rb
|
|
23
|
+
- lib/withruntime/agents.rb
|
|
23
24
|
- lib/withruntime/api_defaults.rb
|
|
24
25
|
- lib/withruntime/client.rb
|
|
25
26
|
- lib/withruntime/credentials.rb
|