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.
@@ -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
@@ -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. Accepts an `options` hash keyed either way.
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
- body = params.dup
198
- key = body.key?(:options) ? :options : "options"
199
- options = (body[key] || {}).dup
200
- options.delete(:streamResponse)
201
- options.delete("streamResponse")
202
- options[:streamResponse] = streaming
203
- body[key] = options
204
- body
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; pass flow: <id> or agent: <id>."
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
- # Copies the body so the caller's nested `record` is not mutated, then
257
- # coerces `record.id` to a string to match the canonical wire contract.
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
- key = body.key?(:record) ? :record : "record"
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
- id_key = record.key?(:id) ? :id : "id"
267
- return body unless record.key?(id_key)
284
+ rewrite_body_key(body, :record) do |record|
285
+ next record unless record.is_a?(Hash)
268
286
 
269
- copy = body.dup
270
- record_copy = record.dup
271
- record_copy[id_key] = String(record[id_key])
272
- copy[key] = record_copy
273
- copy
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
- connection = open_connection(path, payload, request_options, streaming: false)
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&.close
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: url_for(path, request_options),
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
 
@@ -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