runtype 0.1.0 → 0.2.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/CHANGELOG.md +25 -0
- data/README.md +92 -6
- data/generated/lib/runtype/model_configs/types/post_v1model_configs_request_settings_custom_model.rb +2 -0
- data/generated/lib/runtype/model_configs/types/post_v1model_configs_request_settings_custom_model_reasoning.rb +13 -0
- data/generated/lib/runtype/model_configs/types/put_v1model_configs_id_request_settings_custom_model.rb +2 -0
- data/generated/lib/runtype/model_configs/types/put_v1model_configs_id_request_settings_custom_model_reasoning.rb +13 -0
- data/generated/lib/runtype/provider_keys/types/post_v1provider_keys_id_sync_models_request_models_item.rb +2 -0
- data/generated/lib/runtype/provider_keys/types/post_v1provider_keys_id_sync_models_request_models_item_reasoning.rb +13 -0
- data/generated/lib/runtype.rb +3 -0
- data/lib/runtype/agent_definition.rb +331 -0
- data/lib/runtype/agent_ensure.rb +110 -0
- data/lib/runtype/canonical_json.rb +89 -0
- data/lib/runtype/client.rb +64 -28
- data/lib/runtype/errors.rb +91 -1
- data/lib/runtype/local_tool_runner.rb +121 -0
- data/lib/runtype/local_tools.rb +258 -0
- data/lib/runtype/sdk_version.rb +1 -1
- data/lib/runtype.rb +6 -1
- metadata +10 -2
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Runtype
|
|
6
|
+
# Agent config-as-code on {Runtype::Client}: converge a repo-owned
|
|
7
|
+
# {AgentDefinition} onto the platform (`POST /v1/agents/ensure`) and pull the
|
|
8
|
+
# platform's definition back (`GET /v1/agents/pull`). Protocol:
|
|
9
|
+
# `docs/adr/0003-agent-config-as-code-ensure.md`.
|
|
10
|
+
module AgentEnsure
|
|
11
|
+
# Idempotently makes the platform's definition of this agent match
|
|
12
|
+
# `definition`. Identity is the agent name within the API key's account
|
|
13
|
+
# scope. Hash-first: probes with the local content hash and only ships the
|
|
14
|
+
# full definition when the server reports a miss. Every change appends an
|
|
15
|
+
# immutable version; nothing is ever deleted. Non-executing.
|
|
16
|
+
#
|
|
17
|
+
# @param definition [Runtype::AgentDefinition, Hash] see {Runtype.define_agent}.
|
|
18
|
+
# @param dry_run [Boolean] plan without writing (the CI drift gate).
|
|
19
|
+
# @param expect_no_changes [Boolean] implies `dry_run`; raises
|
|
20
|
+
# {Runtype::DriftError} unless the plan is `"none"`.
|
|
21
|
+
# @param on_conflict [String, Symbol, nil] `"error"` (default: 409 when the
|
|
22
|
+
# agent's last write came from the dashboard/API/MCP) or `"overwrite"`.
|
|
23
|
+
# @param release [String, Symbol, nil] `"publish"` also re-aims the
|
|
24
|
+
# published-version pointer; default `"none"`.
|
|
25
|
+
# @param expected_remote_hash [String, nil] binds a dry run to its apply:
|
|
26
|
+
# the write proceeds only if the remote still hashes to this value.
|
|
27
|
+
# @param request_options [Hash] see {Runtype::Client#dispatch}.
|
|
28
|
+
# @return [Hash] `{"result" => "unchanged"|"created"|"updated", "agentId",
|
|
29
|
+
# "versionId", "contentHash"}`, or for a dry run `{"result" => "plan",
|
|
30
|
+
# "changes" => "none"|"create"|"update", "changedKeys", "contentHash", ...}`.
|
|
31
|
+
# `contentHash` is always the server-computed canonical hash.
|
|
32
|
+
# @raise [Runtype::EnsureConflictError] on a 409 (`external_modification`
|
|
33
|
+
# or `remote_changed`).
|
|
34
|
+
# @raise [Runtype::DriftError] with `expect_no_changes:` when the plan is
|
|
35
|
+
# not `"none"`.
|
|
36
|
+
def ensure_agent(definition, dry_run: false, expect_no_changes: false, on_conflict: nil, release: nil,
|
|
37
|
+
expected_remote_hash: nil, request_options: {})
|
|
38
|
+
definition = Runtype::AgentDefinition.from(definition)
|
|
39
|
+
passthrough = {
|
|
40
|
+
onConflict: on_conflict&.to_s,
|
|
41
|
+
release: release&.to_s,
|
|
42
|
+
expectedRemoteHash: expected_remote_hash
|
|
43
|
+
}.compact
|
|
44
|
+
|
|
45
|
+
if dry_run || expect_no_changes
|
|
46
|
+
plan = ensure_request(
|
|
47
|
+
{ name: definition.name, definition: definition.to_h, dryRun: true, **passthrough }, request_options
|
|
48
|
+
)
|
|
49
|
+
unless plan["result"] == "plan"
|
|
50
|
+
raise Runtype::Error, "Expected a plan result from dryRun, got #{plan["result"].inspect}"
|
|
51
|
+
end
|
|
52
|
+
raise Runtype::DriftError.new(plan, name: definition.name) if expect_no_changes && plan["changes"] != "none"
|
|
53
|
+
|
|
54
|
+
return plan
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
local_hash = definition.content_hash
|
|
58
|
+
memo_key = "#{definition.name}:#{local_hash}"
|
|
59
|
+
content_hash = ensure_memo[memo_key] || local_hash
|
|
60
|
+
|
|
61
|
+
probe = ensure_request({ name: definition.name, contentHash: content_hash, **passthrough }, request_options)
|
|
62
|
+
return memoize_ensure(memo_key, probe) unless probe["result"] == "definitionRequired"
|
|
63
|
+
|
|
64
|
+
converged = ensure_request({ name: definition.name, definition: definition.to_h, **passthrough }, request_options)
|
|
65
|
+
if converged["result"] == "definitionRequired"
|
|
66
|
+
raise Runtype::Error, "Server reported definitionRequired for a full-definition request"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
memoize_ensure(memo_key, converged)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The canonical definition and provenance of an agent by name — the
|
|
73
|
+
# absorb-drift direction. `contentHash` reflects the live agent state.
|
|
74
|
+
#
|
|
75
|
+
# @param name [String]
|
|
76
|
+
# @param request_options [Hash]
|
|
77
|
+
# @return [Hash] `{"agentId", "definition" => {...}, "contentHash",
|
|
78
|
+
# "lastModifiedSource", "updatedAt", "versionId", "warnings"?}`.
|
|
79
|
+
# @raise [Runtype::NotFoundError] when no agent by that name is in scope.
|
|
80
|
+
def pull_agent(name, request_options: {})
|
|
81
|
+
get_json("v1/agents/pull", { name: name }, request_options)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# @return [Hash{String => String}] `name:localHash` => server hash, so a
|
|
87
|
+
# hot process pays the probe once per definition.
|
|
88
|
+
def ensure_memo
|
|
89
|
+
@ensure_memo ||= {}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @param memo_key [String]
|
|
93
|
+
# @param result [Hash]
|
|
94
|
+
# @return [Hash] the result, for chaining.
|
|
95
|
+
def memoize_ensure(memo_key, result)
|
|
96
|
+
ensure_memo[memo_key] = result["contentHash"] if result["result"] != "plan" && result["contentHash"]
|
|
97
|
+
result
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Hash] the decoded response body.
|
|
101
|
+
# @raise [Runtype::EnsureConflictError] for the protocol's two 409 shapes.
|
|
102
|
+
def ensure_request(body, request_options)
|
|
103
|
+
post_json("v1/agents/ensure", body, request_options)
|
|
104
|
+
rescue Runtype::APIStatusError => e
|
|
105
|
+
raise Runtype::EnsureConflictError.from_status_error(e) if Runtype::EnsureConflictError.protocol_conflict?(e)
|
|
106
|
+
|
|
107
|
+
raise
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Runtype
|
|
6
|
+
# Serializes a value exactly as JavaScript's `JSON.stringify` would, so a
|
|
7
|
+
# SHA-256 over the result matches the hash the TypeScript SDK and the API
|
|
8
|
+
# compute for the same definition (the content hash is a wire contract; see
|
|
9
|
+
# `docs/adr/0003-agent-config-as-code-ensure.md`).
|
|
10
|
+
#
|
|
11
|
+
# Ruby's `JSON.generate` already agrees with `JSON.stringify` on strings,
|
|
12
|
+
# booleans, nil, arrays and objects. Numbers are the exception: Ruby prints
|
|
13
|
+
# `1.0` where JavaScript prints `1`, and switches to exponent notation at
|
|
14
|
+
# different magnitudes (`1.0e-05` vs `0.00001`). This module renders numbers
|
|
15
|
+
# with the ECMA-262 `Number::toString` rules over Ruby's shortest round-trip
|
|
16
|
+
# digits, which are the same digits V8 picks.
|
|
17
|
+
module CanonicalJSON
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# @param value [Object] a JSON-compatible value (Hash, Array, String,
|
|
21
|
+
# Numeric, true, false, nil).
|
|
22
|
+
# @return [String] the canonical serialization.
|
|
23
|
+
def generate(value)
|
|
24
|
+
case value
|
|
25
|
+
when Hash
|
|
26
|
+
"{#{value.map { |key, entry| "#{JSON.generate(key.to_s)}:#{generate(entry)}" }.join(",")}}"
|
|
27
|
+
when Array
|
|
28
|
+
"[#{value.map { |entry| generate(entry) }.join(",")}]"
|
|
29
|
+
when Integer
|
|
30
|
+
value.to_s
|
|
31
|
+
when Float
|
|
32
|
+
number_literal(value)
|
|
33
|
+
when String, true, false, nil
|
|
34
|
+
JSON.generate(value)
|
|
35
|
+
else
|
|
36
|
+
raise ArgumentError, "CanonicalJSON cannot serialize #{value.class}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Renders a Float the way `Number.prototype.toString` does.
|
|
41
|
+
#
|
|
42
|
+
# @param value [Float]
|
|
43
|
+
# @return [String]
|
|
44
|
+
def number_literal(value)
|
|
45
|
+
raise ArgumentError, "CanonicalJSON cannot serialize #{value}" unless value.finite?
|
|
46
|
+
return "0" if value.zero?
|
|
47
|
+
|
|
48
|
+
sign = value.negative? ? "-" : ""
|
|
49
|
+
digits, exponent = shortest_digits(value.abs)
|
|
50
|
+
k = digits.length
|
|
51
|
+
n = exponent # the decimal point sits after the first n digits
|
|
52
|
+
|
|
53
|
+
body =
|
|
54
|
+
if n.between?(k, 21)
|
|
55
|
+
digits + ("0" * (n - k))
|
|
56
|
+
elsif n.between?(1, 21)
|
|
57
|
+
"#{digits[0, n]}.#{digits[n..]}"
|
|
58
|
+
elsif n.between?(-5, 0)
|
|
59
|
+
"0.#{"0" * -n}#{digits}"
|
|
60
|
+
else
|
|
61
|
+
mantissa = k == 1 ? digits : "#{digits[0]}.#{digits[1..]}"
|
|
62
|
+
e = n - 1
|
|
63
|
+
"#{mantissa}e#{e.negative? ? "-" : "+"}#{e.abs}"
|
|
64
|
+
end
|
|
65
|
+
sign + body
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Splits a positive finite Float into its shortest round-trip decimal
|
|
69
|
+
# digits (no leading or trailing zeros) and the position of the decimal
|
|
70
|
+
# point relative to those digits, per ECMA-262's (s, k, n) decomposition.
|
|
71
|
+
#
|
|
72
|
+
# @param value [Float] positive, finite, non-zero.
|
|
73
|
+
# @return [Array(String, Integer)]
|
|
74
|
+
def shortest_digits(value)
|
|
75
|
+
mantissa, exponent = value.to_s.split("e")
|
|
76
|
+
exp = exponent ? Integer(exponent, 10) : 0
|
|
77
|
+
integer_part, fraction_part = mantissa.split(".")
|
|
78
|
+
fraction_part ||= ""
|
|
79
|
+
raw = integer_part + fraction_part
|
|
80
|
+
point = integer_part.length + exp
|
|
81
|
+
|
|
82
|
+
stripped = raw.sub(/\A0+/, "")
|
|
83
|
+
point -= raw.length - stripped.length
|
|
84
|
+
stripped = stripped.sub(/0+\z/, "")
|
|
85
|
+
[stripped, point]
|
|
86
|
+
end
|
|
87
|
+
private_class_method :shortest_digits
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/runtype/client.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "uri"
|
|
4
5
|
|
|
5
6
|
module Runtype
|
|
6
7
|
# The Runtype API client.
|
|
@@ -24,6 +25,9 @@ module Runtype
|
|
|
24
25
|
# Configuration is per client. There is no global state to set up, so two
|
|
25
26
|
# clients with different keys or base URLs can coexist in one process.
|
|
26
27
|
class Client
|
|
28
|
+
include Runtype::AgentEnsure
|
|
29
|
+
include Runtype::LocalToolRunner
|
|
30
|
+
|
|
27
31
|
# Per-operation socket timeout, in seconds. Generous by default: an
|
|
28
32
|
# execution stream can idle between events while a model is thinking.
|
|
29
33
|
DEFAULT_TIMEOUT = 600
|
|
@@ -188,20 +192,35 @@ module Runtype
|
|
|
188
192
|
end
|
|
189
193
|
|
|
190
194
|
# Forces the streaming preference the caller asked for, leaving the rest of
|
|
191
|
-
# the request body untouched.
|
|
195
|
+
# the request body untouched.
|
|
192
196
|
#
|
|
193
197
|
# @param params [Hash]
|
|
194
198
|
# @param streaming [Boolean]
|
|
195
199
|
# @return [Hash]
|
|
196
200
|
def with_stream_option(params, streaming)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
201
|
+
rewrite_body_key(params, :options) do |options|
|
|
202
|
+
options = (options || {}).dup
|
|
203
|
+
options.delete(:streamResponse)
|
|
204
|
+
options.delete("streamResponse")
|
|
205
|
+
options.merge(streamResponse: streaming)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Copies `body` and replaces one top-level key with the block's result.
|
|
210
|
+
# The key is looked up as a Symbol first, then as a String, and rewritten
|
|
211
|
+
# under whichever spelling the caller used (Symbol when absent), so every
|
|
212
|
+
# dispatch-body normalization agrees on one rule.
|
|
213
|
+
#
|
|
214
|
+
# @param body [Hash]
|
|
215
|
+
# @param key [Symbol]
|
|
216
|
+
# @yieldparam current [Object, nil] the existing value.
|
|
217
|
+
# @return [Hash]
|
|
218
|
+
def rewrite_body_key(body, key)
|
|
219
|
+
found = key
|
|
220
|
+
found = key.to_s if !body.key?(key) && body.key?(key.to_s)
|
|
221
|
+
copy = body.dup
|
|
222
|
+
copy[found] = yield(copy[found])
|
|
223
|
+
copy
|
|
205
224
|
end
|
|
206
225
|
|
|
207
226
|
# Builds the dispatch request body: validates the target, rejects
|
|
@@ -224,7 +243,8 @@ module Runtype
|
|
|
224
243
|
params.key?(:agent) || params.key?("agent")
|
|
225
244
|
|
|
226
245
|
raise ArgumentError,
|
|
227
|
-
"Dispatch requires either a flow or an agent target;
|
|
246
|
+
"Dispatch requires either a flow or an agent target; " \
|
|
247
|
+
"pass flow: { id: \"flow_...\" } or agent: { agentId: \"agent_...\" }."
|
|
228
248
|
end
|
|
229
249
|
|
|
230
250
|
# @param params [Hash]
|
|
@@ -253,24 +273,22 @@ module Runtype
|
|
|
253
273
|
end
|
|
254
274
|
end
|
|
255
275
|
|
|
256
|
-
#
|
|
257
|
-
#
|
|
276
|
+
# Coerces `record.id` to a String to match the canonical wire contract,
|
|
277
|
+
# without mutating the caller's nested `record`.
|
|
258
278
|
#
|
|
259
279
|
# @param body [Hash]
|
|
260
280
|
# @return [Hash]
|
|
261
281
|
def normalize_record_id!(body)
|
|
262
|
-
|
|
263
|
-
record = body[key]
|
|
264
|
-
return body unless record.is_a?(Hash)
|
|
282
|
+
return body unless body.key?(:record) || body.key?("record")
|
|
265
283
|
|
|
266
|
-
|
|
267
|
-
|
|
284
|
+
rewrite_body_key(body, :record) do |record|
|
|
285
|
+
next record unless record.is_a?(Hash)
|
|
268
286
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
287
|
+
id_key = record.key?(:id) ? :id : "id"
|
|
288
|
+
next record unless record.key?(id_key) && !record[id_key].nil?
|
|
289
|
+
|
|
290
|
+
record.merge(id_key => record[id_key].to_s)
|
|
291
|
+
end
|
|
274
292
|
end
|
|
275
293
|
|
|
276
294
|
# @return [Hash]
|
|
@@ -300,12 +318,26 @@ module Runtype
|
|
|
300
318
|
|
|
301
319
|
# @return [Hash] the decoded response body.
|
|
302
320
|
def post_json(path, payload, request_options)
|
|
303
|
-
|
|
321
|
+
read_json(open_connection(path, payload, request_options, streaming: false))
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# @param query [Hash] query parameters.
|
|
325
|
+
# @return [Hash] the decoded response body.
|
|
326
|
+
def get_json(path, query, request_options)
|
|
327
|
+
read_json(open_connection(path, nil, request_options, streaming: false, method: "GET", query: query))
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Reads a non-streaming response to completion, raises on a non-2xx
|
|
331
|
+
# status, and decodes the body.
|
|
332
|
+
#
|
|
333
|
+
# @param connection [Runtype::Streaming::Connection] a started connection.
|
|
334
|
+
# @return [Hash]
|
|
335
|
+
def read_json(connection)
|
|
304
336
|
body = connection.read_body
|
|
305
337
|
raise_for_status(connection, body)
|
|
306
338
|
body.strip.empty? ? {} : parse_json(body)
|
|
307
339
|
ensure
|
|
308
|
-
connection
|
|
340
|
+
connection.close
|
|
309
341
|
end
|
|
310
342
|
|
|
311
343
|
# @return [Runtype::Streaming::EventStream]
|
|
@@ -325,13 +357,17 @@ module Runtype
|
|
|
325
357
|
event_stream
|
|
326
358
|
end
|
|
327
359
|
|
|
360
|
+
# @param query [Hash, nil] appended to the URL as a query string.
|
|
328
361
|
# @return [Runtype::Streaming::Connection]
|
|
329
|
-
def open_connection(path, payload, request_options, streaming:)
|
|
362
|
+
def open_connection(path, payload, request_options, streaming:, method: "POST", query: nil)
|
|
363
|
+
url = url_for(path, request_options)
|
|
364
|
+
url = "#{url}?#{URI.encode_www_form(query)}" if query && !query.empty?
|
|
330
365
|
Runtype::Streaming::Connection.new(
|
|
331
|
-
url:
|
|
366
|
+
url: url,
|
|
332
367
|
headers: headers(streaming: streaming, request_options: request_options),
|
|
333
|
-
body: JSON.generate(payload),
|
|
334
|
-
timeout: timeout_for(request_options)
|
|
368
|
+
body: payload.nil? ? nil : JSON.generate(payload),
|
|
369
|
+
timeout: timeout_for(request_options),
|
|
370
|
+
method: method
|
|
335
371
|
).start
|
|
336
372
|
end
|
|
337
373
|
|
data/lib/runtype/errors.rb
CHANGED
|
@@ -70,7 +70,6 @@ module Runtype
|
|
|
70
70
|
|
|
71
71
|
"API error: #{status}"
|
|
72
72
|
end
|
|
73
|
-
private_class_method :message_from
|
|
74
73
|
end
|
|
75
74
|
|
|
76
75
|
# An error response from the API. Raised directly for status codes without a
|
|
@@ -104,6 +103,7 @@ module Runtype
|
|
|
104
103
|
when 401 then AuthenticationError
|
|
105
104
|
when 403 then PermissionDeniedError
|
|
106
105
|
when 404 then NotFoundError
|
|
106
|
+
when 409 then ConflictError
|
|
107
107
|
when 422 then UnprocessableEntityError
|
|
108
108
|
when 429 then RateLimitError
|
|
109
109
|
else APIStatusError
|
|
@@ -125,6 +125,96 @@ module Runtype
|
|
|
125
125
|
# 404.
|
|
126
126
|
class NotFoundError < APIStatusError; end
|
|
127
127
|
|
|
128
|
+
# 409.
|
|
129
|
+
class ConflictError < APIStatusError; end
|
|
130
|
+
|
|
131
|
+
# 409 from the agent ensure protocol: the remote changed underneath the
|
|
132
|
+
# caller. `code` is `"external_modification"` (the agent's last write came
|
|
133
|
+
# from the dashboard/API/MCP; pass `on_conflict: "overwrite"` to converge
|
|
134
|
+
# over it) or `"remote_changed"` (the `expected_remote_hash` no longer
|
|
135
|
+
# matches; re-run the dry run).
|
|
136
|
+
class EnsureConflictError < ConflictError
|
|
137
|
+
PROTOCOL_CODES = %w[external_modification remote_changed].freeze
|
|
138
|
+
|
|
139
|
+
# @return [String] `"external_modification"` or `"remote_changed"`.
|
|
140
|
+
def code
|
|
141
|
+
body_field("code")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @return [String, nil] who last wrote the agent (external_modification).
|
|
145
|
+
def last_modified_source
|
|
146
|
+
body_field("lastModifiedSource")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [String, nil] ISO-8601 (external_modification).
|
|
150
|
+
def modified_at
|
|
151
|
+
body_field("modifiedAt")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @return [String, nil] the live hash (remote_changed).
|
|
155
|
+
def current_hash
|
|
156
|
+
body_field("currentHash")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @param error [Runtype::APIStatusError]
|
|
160
|
+
# @return [Boolean]
|
|
161
|
+
def self.protocol_conflict?(error)
|
|
162
|
+
error.status_code == 409 && error.body.is_a?(Hash) && PROTOCOL_CODES.include?(error.body["code"])
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @param error [Runtype::APIStatusError]
|
|
166
|
+
# @return [Runtype::EnsureConflictError]
|
|
167
|
+
def self.from_status_error(error)
|
|
168
|
+
new(
|
|
169
|
+
Runtype::Error.message_from(error.body, error.status_code),
|
|
170
|
+
status_code: error.status_code, body: error.body, headers: error.headers
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
# @param key [String]
|
|
177
|
+
# @return [Object, nil]
|
|
178
|
+
def body_field(key)
|
|
179
|
+
body.is_a?(Hash) ? body[key] : nil
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Raised by `ensure_agent(definition, expect_no_changes: true)` when the
|
|
184
|
+
# platform's definition no longer matches the repo's.
|
|
185
|
+
class DriftError < Error
|
|
186
|
+
# @return [Hash] the dry-run plan: `"changes"`, `"changedKeys"`,
|
|
187
|
+
# `"contentHash"`, `"remoteHash"`, `"agentId"`.
|
|
188
|
+
attr_reader :plan
|
|
189
|
+
|
|
190
|
+
# @param plan [Hash]
|
|
191
|
+
# @param name [String, nil]
|
|
192
|
+
def initialize(plan, name: nil)
|
|
193
|
+
@plan = plan
|
|
194
|
+
changed = Array(plan["changedKeys"]).join(", ")
|
|
195
|
+
super(
|
|
196
|
+
"Agent #{(name || plan["agentId"] || "definition").inspect} drifted: plan is #{plan["changes"].inspect} " \
|
|
197
|
+
"(changed: #{changed.empty? ? "n/a" : changed}). Run client.pull_agent(name) to absorb the remote edit " \
|
|
198
|
+
"into your repo, or re-run ensure_agent to converge."
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# A local tool could not be run during `run_with_local_tools`: the server
|
|
204
|
+
# paused on a tool with no handler, the handler raised (the original error is
|
|
205
|
+
# `#cause`), or the loop exceeded its round limit.
|
|
206
|
+
class LocalToolError < Error
|
|
207
|
+
# @return [String, nil] the tool the server paused on.
|
|
208
|
+
attr_reader :tool_name
|
|
209
|
+
|
|
210
|
+
# @param message [String]
|
|
211
|
+
# @param tool_name [String, nil]
|
|
212
|
+
def initialize(message, tool_name: nil)
|
|
213
|
+
@tool_name = tool_name
|
|
214
|
+
super(message)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
128
218
|
# 422.
|
|
129
219
|
class UnprocessableEntityError < APIStatusError; end
|
|
130
220
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Runtype
|
|
4
|
+
# The local-tool pause/resume loop on {Runtype::Client}.
|
|
5
|
+
module LocalToolRunner
|
|
6
|
+
# Upper bound on resume legs for one call, so a tool the server keeps
|
|
7
|
+
# re-requesting cannot spin forever.
|
|
8
|
+
DEFAULT_MAX_LOCAL_TOOL_ROUNDS = 50
|
|
9
|
+
|
|
10
|
+
# Dispatches, and whenever the execution pauses on a tool in `tools`, runs
|
|
11
|
+
# it here and resumes with the output — repeating until the execution ends
|
|
12
|
+
# or pauses on something this process does not own (an approval or an
|
|
13
|
+
# elicitation, for instance), which is returned to the caller as-is.
|
|
14
|
+
#
|
|
15
|
+
# Every parallel local tool call in one model turn is executed
|
|
16
|
+
# concurrently and answered in a single resume. Outputs are keyed by the
|
|
17
|
+
# server's per-call id when it sent one, else by tool name.
|
|
18
|
+
#
|
|
19
|
+
# Without a block the loop runs over JSON responses and returns the final
|
|
20
|
+
# response body. With a block it streams: every event of every leg is
|
|
21
|
+
# yielded, and the last leg's closed {Runtype::Streaming::EventStream} is
|
|
22
|
+
# returned so its reconnect handle stays readable.
|
|
23
|
+
#
|
|
24
|
+
# result = client.run_with_local_tools(
|
|
25
|
+
# { "save_goals" => save_goals, "lookup_order" => ->(args) { Orders.find(args["id"]) } },
|
|
26
|
+
# agent: { agentId: "agent_01h..." },
|
|
27
|
+
# messages: [{ role: "user", content: "I want to sleep better" }]
|
|
28
|
+
# )
|
|
29
|
+
#
|
|
30
|
+
# @param tools [Hash{String, Symbol => Runtype::LocalTool, #call}] tool
|
|
31
|
+
# name => handler. A {Runtype::LocalTool} carries the schema the model
|
|
32
|
+
# sees; a bare callable relies on the saved agent already declaring the
|
|
33
|
+
# tool as a `toolType: "local"` runtime tool.
|
|
34
|
+
# @param scope [Symbol, String] `:session` (default) sends no tool
|
|
35
|
+
# declarations — the saved agent owns them. `:turn` derives
|
|
36
|
+
# `clientTools[]` from every {Runtype::LocalTool} in `tools` and ships
|
|
37
|
+
# them in the dispatch envelope for this one turn (on top of any
|
|
38
|
+
# `clientTools:` already in `params`).
|
|
39
|
+
# @param max_rounds [Integer] see {DEFAULT_MAX_LOCAL_TOOL_ROUNDS}.
|
|
40
|
+
# @param request_options [Hash] see {Runtype::Client#dispatch}.
|
|
41
|
+
# @param params [Hash] the dispatch request body (`agent:`, `messages:`, ...).
|
|
42
|
+
# @yieldparam event [Runtype::Streaming::Event] when streaming.
|
|
43
|
+
# @return [Hash, Runtype::Streaming::EventStream]
|
|
44
|
+
# @raise [Runtype::LocalToolError] when a paused tool has no handler, a
|
|
45
|
+
# handler raises, or `max_rounds` is exceeded.
|
|
46
|
+
def run_with_local_tools(tools, scope: :session, max_rounds: DEFAULT_MAX_LOCAL_TOOL_ROUNDS,
|
|
47
|
+
request_options: {}, **params, &block)
|
|
48
|
+
registry = Runtype::LocalTools::Registry.new(tools)
|
|
49
|
+
params = with_turn_scoped_client_tools(params, registry) if scope.to_s == "turn"
|
|
50
|
+
|
|
51
|
+
if block
|
|
52
|
+
stream_with_local_tools(registry, params, max_rounds, request_options, &block)
|
|
53
|
+
else
|
|
54
|
+
dispatch_with_local_tools(registry, params, max_rounds, request_options)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# @return [Hash] the final JSON response body.
|
|
61
|
+
def dispatch_with_local_tools(registry, params, max_rounds, request_options)
|
|
62
|
+
response = dispatch(request_options: request_options, **params)
|
|
63
|
+
rounds = 0
|
|
64
|
+
|
|
65
|
+
while response["status"] == "paused"
|
|
66
|
+
collector = Runtype::LocalTools::PauseCollector.new
|
|
67
|
+
Array(response["events"]).each { |event| collector.observe(event) }
|
|
68
|
+
collector.observe_paused_reason(response["pausedReason"]) unless collector.paused?
|
|
69
|
+
return response unless collector.paused?
|
|
70
|
+
|
|
71
|
+
raise_local_tool_round_limit(max_rounds) if (rounds += 1) > max_rounds
|
|
72
|
+
response = resume(
|
|
73
|
+
execution_id: collector.batch_execution_id,
|
|
74
|
+
tool_outputs: registry.execute(collector.batch),
|
|
75
|
+
request_options: request_options
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
response
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Runtype::Streaming::EventStream] the last leg's stream.
|
|
83
|
+
def stream_with_local_tools(registry, params, max_rounds, request_options)
|
|
84
|
+
collector = Runtype::LocalTools::PauseCollector.new
|
|
85
|
+
stream = dispatch_stream(request_options: request_options, **params) do |event|
|
|
86
|
+
collector.observe(event)
|
|
87
|
+
yield event
|
|
88
|
+
end
|
|
89
|
+
rounds = 0
|
|
90
|
+
|
|
91
|
+
while collector.paused?
|
|
92
|
+
raise_local_tool_round_limit(max_rounds) if (rounds += 1) > max_rounds
|
|
93
|
+
outputs = registry.execute(collector.batch)
|
|
94
|
+
execution_id = collector.batch_execution_id
|
|
95
|
+
collector = Runtype::LocalTools::PauseCollector.new
|
|
96
|
+
stream = resume_stream(
|
|
97
|
+
execution_id: execution_id, tool_outputs: outputs, request_options: request_options
|
|
98
|
+
) do |event|
|
|
99
|
+
collector.observe(event)
|
|
100
|
+
yield event
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
stream
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @raise [Runtype::LocalToolError]
|
|
108
|
+
def raise_local_tool_round_limit(max_rounds)
|
|
109
|
+
raise Runtype::LocalToolError, "Execution kept pausing on local tools after #{max_rounds} resume rounds"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [Hash] `params` with the registry's schema-carrying tools appended
|
|
113
|
+
# to `clientTools` (keyed however the caller keyed it).
|
|
114
|
+
def with_turn_scoped_client_tools(params, registry)
|
|
115
|
+
derived = registry.client_tools
|
|
116
|
+
return params if derived.empty?
|
|
117
|
+
|
|
118
|
+
rewrite_body_key(params, :clientTools) { |existing| Array(existing) + derived }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|