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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 88d76ad46808e8a8a0c06eb80f6b2ff0c61065f12d9762884af36689151f7e37
4
- data.tar.gz: ac201871d379ec67b60e7cb6d34b1df429c4723a26b2a5843225ed8d6bbe9ea5
3
+ metadata.gz: 14f06d191353192fea8029429fafaaf84fdc1237a0798fca43b2eee1125d1f09
4
+ data.tar.gz: 7b3565f5f24b5c5c427bf373b11e084fd8ae4eb3c39caae4ea34e7840174022e
5
5
  SHA512:
6
- metadata.gz: 74823bde1a17d632a6a7cb59499e3233452401c157c736f5a5408bd45b37977d2962637841ce2cfa6cffcc8d4b1cc56568235d2db860c780130c9f8a9c9842b3
7
- data.tar.gz: 02d0a9123404ebea9d4350e4ed019a96f37db67f9d741b63d7c4d53da6ffc92cc2b89c3247f700aa9a735985fd15a31d98a72e2bd1f37dfcf2e6d3ce36d4fbc7
6
+ metadata.gz: 4ccf025ce694d254683aebd5d7838abb44f41fceabdf4835c1fdd404fbed4110d37489dd6ffb0fbc4c2ceb19ab8f4535e6dec8340f55ad685c3326aa6f1c30f8
7
+ data.tar.gz: e9922363afc2c3e43208db368948fd30b71d1145bad41626a043c8a90100ba8b803db0f68e38102b39d0088fa9fe678e9f578320089866f490f185ae9d875563
data/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to the Runtype Ruby SDK are documented here.
4
4
 
5
+ ## 0.2.0
6
+
7
+ - Agents as code: `Runtype.define_agent` builds a validated, immutable
8
+ `Runtype::AgentDefinition` (snake_case or wire camelCase top-level fields, nested
9
+ config passed through untouched, account-scoped `tool_…`/`agent_…`/`flow_…` refs
10
+ rejected). `Runtype::Client#ensure_agent` converges it via `POST /v1/agents/ensure`
11
+ with the hash-first probe, per-client memo of the server hash, `dry_run:`,
12
+ `expect_no_changes:` (raises `Runtype::DriftError`), `on_conflict:`, `release:` and
13
+ `expected_remote_hash:`; `#pull_agent` reads the platform's definition back.
14
+ - `Runtype::AgentDefinition.content_hash_for` computes the platform's canonical
15
+ content hash: object keys ordered as `JSON.stringify` emits them and numbers
16
+ rendered as JavaScript renders them, so the hash matches byte for byte (asserted
17
+ against the shared parity corpus). `ensure_agent` also accepts the wire-shaped
18
+ definition `pull_agent` returns.
19
+ - `Runtype::Client#run_with_local_tools`: the local-tool pause/resume loop. Executes
20
+ every paused tool in a batch concurrently and answers them in one resume keyed by
21
+ the server's per-call id, in JSON or streaming mode, and returns a pause this
22
+ process does not own (approval, elicitation, detached) untouched. `Runtype::LocalTool` pairs a handler with its schema; `scope: :turn`
23
+ ships those schemas as `clientTools[]`.
24
+ - Curated errors: `Runtype::ConflictError` (409), `Runtype::EnsureConflictError`,
25
+ `Runtype::DriftError`, `Runtype::LocalToolError`.
26
+ - The README's dispatch examples now show the wire shape, `agent: { agentId: }` with
27
+ real `agent_…` ids (the previous `agent: { id: "agt_…" }` examples never referenced
28
+ a saved agent), and the missing-target error names it.
29
+
5
30
  ## 0.1.0
6
31
 
7
32
  Initial release.
data/README.md CHANGED
@@ -25,7 +25,7 @@ client = Runtype::Client.new(api_key: ENV["RUNTYPE_API_KEY"])
25
25
 
26
26
  # Stream an agent turn
27
27
  client.dispatch_stream(
28
- agent: { id: "agt_abc123" },
28
+ agent: { agentId: "agent_abc123" },
29
29
  messages: [{ role: "user", content: "Summarize last quarter" }]
30
30
  ) do |event|
31
31
  print event["delta"] if event.type == "text_delta"
@@ -33,16 +33,71 @@ end
33
33
 
34
34
  # Or wait for the finished result
35
35
  result = client.dispatch(
36
- agent: { id: "agt_abc123" },
36
+ agent: { agentId: "agent_abc123" },
37
37
  messages: [{ role: "user", content: "Summarize last quarter" }]
38
38
  )
39
39
  puts result["executionId"]
40
40
 
41
41
  # Every documented endpoint is a resource group on the client
42
42
  flows = client.flows.list_flows
43
- agent = client.agents.get_agent_details(id: "agt_abc123")
43
+ agent = client.agents.get_agent_details(id: "agent_abc123")
44
44
  ```
45
45
 
46
+ The dispatch `agent:` object is an agent definition. Reference a saved agent with
47
+ `agentId:`, or run an inline one with `name:`, `model:`, `systemPrompt:` and the rest
48
+ of the runtime config — nothing is persisted for inline agents.
49
+
50
+ ## Agents as code
51
+
52
+ Keep the agent's definition in your repo and converge it onto the platform at boot or
53
+ deploy time. `Runtype.define_agent` is pure and local; `ensure_agent` is idempotent
54
+ and non-executing: it probes with a content hash, ships the full definition only when
55
+ the platform reports a miss, appends an immutable version on every change, and never
56
+ deletes. Identity is the agent's name within the API key's account scope.
57
+
58
+ ```ruby
59
+ assistant = Runtype.define_agent(
60
+ name: "Pricing Assistant",
61
+ model: "claude-sonnet-4-6",
62
+ system_prompt: render_prompt(pricing_data),
63
+ loop_config: { maxTurns: 1 },
64
+ tools: {
65
+ toolIds: ["builtin:web_search", "tool:Price List"], # portable refs only
66
+ runtimeTools: [
67
+ { name: "save_quote", toolType: "local", description: "Save a quote",
68
+ parametersSchema: { type: "object", properties: { total: { type: "number" } } } }
69
+ ]
70
+ }
71
+ )
72
+
73
+ result = client.ensure_agent(assistant, on_conflict: "overwrite", release: "publish")
74
+ result["result"] # "unchanged" | "created" | "updated"
75
+ result["agentId"] # dispatch with agent: { agentId: result["agentId"] }
76
+
77
+ # CI drift gate: raises Runtype::DriftError unless the platform already matches
78
+ client.ensure_agent(assistant, expect_no_changes: true)
79
+
80
+ # Absorb a dashboard edit back into the repo
81
+ pulled = client.pull_agent("Pricing Assistant")
82
+ pulled["definition"] # the canonical wire definition; ensure_agent accepts it as-is
83
+ pulled["lastModifiedSource"]
84
+ ```
85
+
86
+ Top-level fields take snake_case or the wire's camelCase; nested config (`tools`,
87
+ `loop_config`, ...) is passed through in wire shape untouched, because its keys can be
88
+ tool names. Definitions must be environment-portable: raw `tool_…`, `agent_…` and
89
+ `flow_…` ids are rejected in favour of `builtin:`, `platform:`, `mcp:` and
90
+ `tool:<name>` / `agent:<name>` / `flow:<name>` references, so the same definition
91
+ converges staging and production.
92
+
93
+ `ensure_agent` raises `Runtype::EnsureConflictError` (a 409) when the agent's last
94
+ write came from the dashboard, the API or MCP (`#code == "external_modification"`;
95
+ pass `on_conflict: "overwrite"` to converge over it) or when an
96
+ `expected_remote_hash:` no longer matches (`#code == "remote_changed"`).
97
+ `Runtype::AgentDefinition.content_hash_for(definition)` computes the same SHA-256 the
98
+ platform does, over the canonical normalized form (keys ordered as `JSON.stringify`
99
+ emits them, numbers rendered as JavaScript renders them).
100
+
46
101
  ## Authentication
47
102
 
48
103
  Pass `api_key:`, or leave it out and the client reads `RUNTYPE_API_KEY`. The base URL
@@ -67,7 +122,7 @@ events. Passing a block iterates it and closes the socket for you; the closed st
67
122
  comes back so the reconnect handle is still readable.
68
123
 
69
124
  ```ruby
70
- stream = client.dispatch_stream(agent: { id: "agt_abc123" }, messages: messages) do |event|
125
+ stream = client.dispatch_stream(agent: { agentId: "agent_abc123" }, messages: messages) do |event|
71
126
  case event.type
72
127
  when "text_delta" then print event["delta"]
73
128
  when "tool_start" then puts "calling #{event["toolName"]}"
@@ -109,7 +164,7 @@ from where you left off.
109
164
  ```ruby
110
165
  detached = false
111
166
 
112
- stream = client.dispatch_stream(agent: { id: "agt_abc123" }, messages: messages) do |event|
167
+ stream = client.dispatch_stream(agent: { agentId: "agent_abc123" }, messages: messages) do |event|
113
168
  detached ||= event.detached?
114
169
  handle(event)
115
170
  end
@@ -130,6 +185,33 @@ client.resume_stream(
130
185
 
131
186
  `resume` is the non-streaming equivalent. Neither counts against execution limits.
132
187
 
188
+ `run_with_local_tools` runs that loop for you. Give it a map of tool name to handler;
189
+ every time the execution pauses on one of them it executes the handler and resumes
190
+ with the output, running all of a turn's parallel calls concurrently and answering
191
+ them in one resume, until the execution ends or pauses on something this process does
192
+ not own (an approval or an elicitation, say), which is returned as-is. Without a block it returns the final JSON body; with a block
193
+ it streams every event of every leg and returns the last leg's stream.
194
+
195
+ ```ruby
196
+ save_goals = Runtype::LocalTool.new(
197
+ description: "Save the patient's stated goals",
198
+ parameters_schema: { type: "object", properties: { goals: { type: "array", items: { type: "string" } } } }
199
+ ) { |args| Goals.save(args["goals"]); { ok: true } }
200
+
201
+ result = client.run_with_local_tools(
202
+ { "save_goals" => save_goals, "lookup_order" => ->(args) { Orders.find(args["id"]) } },
203
+ agent: { agentId: "agent_abc123" },
204
+ messages: [{ role: "user", content: "I want to sleep better" }]
205
+ )
206
+ result["status"] # "completed", or "paused" on a pause you do not own
207
+ ```
208
+
209
+ A bare callable is enough when the saved agent already declares the tool as a
210
+ `toolType: "local"` runtime tool. Pass `scope: :turn` to also ship every
211
+ `Runtype::LocalTool`'s schema as `clientTools[]` for that one dispatch, for tools
212
+ the saved agent does not declare. A missing handler, a handler that raises, or a
213
+ loop that exceeds `max_rounds:` raises `Runtype::LocalToolError`.
214
+
133
215
  ## Errors
134
216
 
135
217
  Everything the client raises descends from `Runtype::Error`.
@@ -139,18 +221,22 @@ Everything the client raises descends from `Runtype::Error`.
139
221
  | `Runtype::AuthenticationError` | 401, or no API key at construction |
140
222
  | `Runtype::PermissionDeniedError` | 403 |
141
223
  | `Runtype::NotFoundError` | 404 |
224
+ | `Runtype::ConflictError` | 409 |
225
+ | `Runtype::EnsureConflictError` | 409 from `ensure_agent`, exposes `#code` |
142
226
  | `Runtype::UnprocessableEntityError` | 422 |
143
227
  | `Runtype::RateLimitError` | 429, exposes `#retry_after` |
144
228
  | `Runtype::APIStatusError` | any other non-2xx |
145
229
  | `Runtype::APITimeoutError` | the request timed out |
146
230
  | `Runtype::APIConnectionError` | the request never reached the API |
231
+ | `Runtype::DriftError` | `ensure_agent(..., expect_no_changes: true)` found drift, exposes `#plan` |
232
+ | `Runtype::LocalToolError` | `run_with_local_tools` could not run a tool, exposes `#tool_name` and `#cause` |
147
233
 
148
234
  Status errors carry `#status_code`, `#body` (parsed when the response was JSON), and
149
235
  `#headers`.
150
236
 
151
237
  ```ruby
152
238
  begin
153
- client.dispatch(agent: { id: "agt_abc123" }, messages: messages)
239
+ client.dispatch(agent: { agentId: "agent_abc123" }, messages: messages)
154
240
  rescue Runtype::RateLimitError => e
155
241
  sleep(e.retry_after || 5)
156
242
  retry
@@ -16,6 +16,8 @@ module Runtype
16
16
 
17
17
  field :output_cost_per1k_tokens, -> { Integer }, optional: false, nullable: false, api_name: "outputCostPer1kTokens"
18
18
 
19
+ field :reasoning, -> { Runtype::ModelConfigs::Types::PostV1ModelConfigsRequestSettingsCustomModelReasoning }, optional: true, nullable: false
20
+
19
21
  field :supported_response_formats, -> { Internal::Types::Array[String] }, optional: true, nullable: false, api_name: "supportedResponseFormats"
20
22
 
21
23
  field :supports_streaming, -> { Internal::Types::Boolean }, optional: true, nullable: false, api_name: "supportsStreaming"
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runtype
4
+ module ModelConfigs
5
+ module Types
6
+ class PostV1ModelConfigsRequestSettingsCustomModelReasoning < Internal::Types::Model
7
+ field :default_effort, -> { String }, optional: true, nullable: false, api_name: "defaultEffort"
8
+
9
+ field :supported, -> { Internal::Types::Boolean }, optional: false, nullable: false
10
+ end
11
+ end
12
+ end
13
+ end
@@ -16,6 +16,8 @@ module Runtype
16
16
 
17
17
  field :output_cost_per1k_tokens, -> { Integer }, optional: false, nullable: false, api_name: "outputCostPer1kTokens"
18
18
 
19
+ field :reasoning, -> { Runtype::ModelConfigs::Types::PutV1ModelConfigsIDRequestSettingsCustomModelReasoning }, optional: true, nullable: false
20
+
19
21
  field :supported_response_formats, -> { Internal::Types::Array[String] }, optional: true, nullable: false, api_name: "supportedResponseFormats"
20
22
 
21
23
  field :supports_streaming, -> { Internal::Types::Boolean }, optional: true, nullable: false, api_name: "supportsStreaming"
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runtype
4
+ module ModelConfigs
5
+ module Types
6
+ class PutV1ModelConfigsIDRequestSettingsCustomModelReasoning < Internal::Types::Model
7
+ field :default_effort, -> { String }, optional: true, nullable: false, api_name: "defaultEffort"
8
+
9
+ field :supported, -> { Internal::Types::Boolean }, optional: false, nullable: false
10
+ end
11
+ end
12
+ end
13
+ end
@@ -14,6 +14,8 @@ module Runtype
14
14
 
15
15
  field :output_cost_per1k_tokens, -> { Integer }, optional: true, nullable: false, api_name: "outputCostPer1kTokens"
16
16
 
17
+ field :reasoning, -> { Runtype::ProviderKeys::Types::PostV1ProviderKeysIDSyncModelsRequestModelsItemReasoning }, optional: true, nullable: false
18
+
17
19
  field :supported_response_formats, -> { Internal::Types::Array[String] }, optional: true, nullable: false, api_name: "supportedResponseFormats"
18
20
 
19
21
  field :supports_streaming, -> { Internal::Types::Boolean }, optional: true, nullable: false, api_name: "supportsStreaming"
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runtype
4
+ module ProviderKeys
5
+ module Types
6
+ class PostV1ProviderKeysIDSyncModelsRequestModelsItemReasoning < Internal::Types::Model
7
+ field :default_effort, -> { String }, optional: true, nullable: false, api_name: "defaultEffort"
8
+
9
+ field :supported, -> { Internal::Types::Boolean }, optional: false, nullable: false
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1126,6 +1126,7 @@ require_relative "runtype/model_configs/types/get_v1model_configs_response"
1126
1126
  require_relative "runtype/model_configs/types/post_v1model_configs_request_provider"
1127
1127
  require_relative "runtype/model_configs/types/post_v1model_configs_request_settings_custom_model_executor_config"
1128
1128
  require_relative "runtype/model_configs/types/post_v1model_configs_request_settings_custom_model_executor_provider"
1129
+ require_relative "runtype/model_configs/types/post_v1model_configs_request_settings_custom_model_reasoning"
1129
1130
  require_relative "runtype/model_configs/types/post_v1model_configs_request_settings_custom_model"
1130
1131
  require_relative "runtype/model_configs/types/post_v1model_configs_request_settings"
1131
1132
  require_relative "runtype/model_configs/types/post_v1model_configs_response_configuration_status"
@@ -1161,6 +1162,7 @@ require_relative "runtype/model_configs/types/get_v1model_configs_usage_response
1161
1162
  require_relative "runtype/model_configs/types/get_v1model_configs_usage_response"
1162
1163
  require_relative "runtype/model_configs/types/put_v1model_configs_id_request_settings_custom_model_executor_config"
1163
1164
  require_relative "runtype/model_configs/types/put_v1model_configs_id_request_settings_custom_model_executor_provider"
1165
+ require_relative "runtype/model_configs/types/put_v1model_configs_id_request_settings_custom_model_reasoning"
1164
1166
  require_relative "runtype/model_configs/types/put_v1model_configs_id_request_settings_custom_model"
1165
1167
  require_relative "runtype/model_configs/types/put_v1model_configs_id_request_settings"
1166
1168
  require_relative "runtype/model_configs/types/put_v1model_configs_id_response_configuration_status"
@@ -1473,6 +1475,7 @@ require_relative "runtype/provider_keys/types/patch_v1provider_keys_id_response_
1473
1475
  require_relative "runtype/provider_keys/types/patch_v1provider_keys_id_response"
1474
1476
  require_relative "runtype/provider_keys/types/post_v1provider_keys_id_discover_models_response_models_item"
1475
1477
  require_relative "runtype/provider_keys/types/post_v1provider_keys_id_discover_models_response"
1478
+ require_relative "runtype/provider_keys/types/post_v1provider_keys_id_sync_models_request_models_item_reasoning"
1476
1479
  require_relative "runtype/provider_keys/types/post_v1provider_keys_id_sync_models_request_models_item"
1477
1480
  require_relative "runtype/provider_keys/types/post_v1provider_keys_id_sync_models_response"
1478
1481
  require_relative "runtype/provider_status/types/get_v1provider_status_response_embedding_models_value"
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Runtype
6
+ # Builds an {AgentDefinition}: the pure-local, no-I/O half of agent
7
+ # config-as-code. Converge it onto the platform with
8
+ # {Runtype::Client#ensure_agent}.
9
+ #
10
+ # assistant = Runtype.define_agent(
11
+ # name: "Pricing Assistant",
12
+ # model: "claude-sonnet-4-6",
13
+ # system_prompt: render_prompt(pricing_data),
14
+ # loop_config: { maxTurns: 1 }
15
+ # )
16
+ #
17
+ # Top-level fields accept snake_case or the wire's camelCase
18
+ # (`system_prompt:` and `systemPrompt:` are the same field). Nested config
19
+ # (`loop_config`, `tools`, ...) is passed through in wire shape untouched:
20
+ # its keys can be tool names such as `"save_patient_goals"`, so they are
21
+ # never case-converted.
22
+ #
23
+ # @param fields [Hash] `name:` plus any of the agent runtime config keys
24
+ # ({AgentDefinition::CONFIG_KEYS}), `description:` and `icon:`.
25
+ # @return [Runtype::AgentDefinition]
26
+ # @raise [ArgumentError] on a missing name, an unknown field, or an
27
+ # account-scoped `tool_…` / `agent_…` / `flow_…` reference.
28
+ def self.define_agent(**fields)
29
+ AgentDefinition.new(fields)
30
+ end
31
+
32
+ # A repo-owned agent definition, validated locally and hashed exactly as the
33
+ # API hashes it (see {#content_hash}). Immutable once built: every String,
34
+ # Hash and Array it holds is a frozen copy of the caller's input.
35
+ class AgentDefinition
36
+ # The agent runtime config surface that `ensure` converges. Keys outside
37
+ # this list are rejected by {Runtype.define_agent} and, on the wire, are
38
+ # excluded from the content hash so a stray field cannot produce a hash
39
+ # the server can never reproduce. Pinned against the generated
40
+ # `AgentPullResponseDefinitionConfig` model by the test suite.
41
+ CONFIG_KEYS = %w[
42
+ model systemPrompt temperature maxTokens topP topK frequencyPenalty
43
+ presencePenalty seed tools reasoning advisor loopConfig voice
44
+ errorHandling artifacts loggingPolicy piiRedaction temporal memory
45
+ sandbox tenancyStrategy durability state
46
+ ].freeze
47
+
48
+ TOP_LEVEL_KEYS = (%w[name description icon] + CONFIG_KEYS).freeze
49
+
50
+ # snake_case spellings of every multi-word top-level key, derived from
51
+ # {CONFIG_KEYS} so the two can never disagree.
52
+ SNAKE_CASE_ALIASES = CONFIG_KEYS.to_h { |key| [key.gsub(/[A-Z]/) { |upper| "_#{upper.downcase}" }, key] }
53
+ .reject { |snake, camel| snake == camel }.freeze
54
+ private_constant :SNAKE_CASE_ALIASES
55
+
56
+ # Largest canonical array index (2**32 - 2). `JSON.stringify` emits such
57
+ # keys first, in ascending numeric order, ahead of every other key.
58
+ MAX_ARRAY_INDEX = 4_294_967_294
59
+ private_constant :MAX_ARRAY_INDEX
60
+
61
+ # @return [String] the agent name — its identity within the account scope.
62
+ attr_reader :name
63
+
64
+ # @return [String, nil]
65
+ attr_reader :description
66
+
67
+ # @return [String, nil]
68
+ attr_reader :icon
69
+
70
+ # @return [Hash{String => Object}] the runtime config, wire-shaped, string keys.
71
+ attr_reader :config
72
+
73
+ # @return [String] SHA-256 (hex) over {#normalized}, computed once at
74
+ # construction. Sent as the hash-only probe in the ensure protocol; the
75
+ # server recomputes and returns its own on every response.
76
+ attr_reader :content_hash
77
+
78
+ # Accepts an existing definition, the flat Hash {Runtype.define_agent}
79
+ # takes, or a wire-shaped definition with a nested `config` (what
80
+ # {Runtype::Client#pull_agent} returns), so a pulled definition can be
81
+ # ensured straight back.
82
+ #
83
+ # @param value [Runtype::AgentDefinition, Hash]
84
+ # @return [Runtype::AgentDefinition]
85
+ def self.from(value)
86
+ return value if value.is_a?(AgentDefinition)
87
+ return new(value) if value.is_a?(Hash)
88
+
89
+ raise ArgumentError, "expected a Runtype::AgentDefinition or a Hash, got #{value.class}"
90
+ end
91
+
92
+ # @param fields [Hash] the flat shape (see {Runtype.define_agent}) or the
93
+ # wire shape (`{"name", "description"?, "icon"?, "config" => {...}}`).
94
+ def initialize(fields)
95
+ raise ArgumentError, "define_agent requires a definition Hash" unless fields.is_a?(Hash)
96
+
97
+ normalized = flatten_wire_shape(fields.to_h { |key, value| [canonical_key(key), value] })
98
+ unknown = normalized.keys - TOP_LEVEL_KEYS
99
+ unless unknown.empty?
100
+ raise ArgumentError,
101
+ "define_agent: unknown field(s): #{unknown.join(", ")}. Allowed fields are name, description, icon, " \
102
+ "and the agent runtime config surface (#{CONFIG_KEYS.join(", ")})."
103
+ end
104
+
105
+ @name = normalized["name"]
106
+ unless @name.is_a?(String) && !@name.empty?
107
+ raise ArgumentError, "define_agent requires a non-empty String \"name\""
108
+ end
109
+
110
+ @name = @name.dup.freeze
111
+ @description = normalized["description"]&.dup&.freeze
112
+ @icon = normalized["icon"]&.dup&.freeze
113
+ @config = CONFIG_KEYS.each_with_object({}) do |key, config|
114
+ value = normalized[key]
115
+ config[key] = deep_freeze(deep_stringify_keys(value)) unless value.nil?
116
+ end.freeze
117
+
118
+ non_portable = non_portable_refs(@config)
119
+ unless non_portable.empty?
120
+ raise ArgumentError,
121
+ "define_agent: account-scoped reference(s) at #{non_portable.join(", ")}. Definitions must be " \
122
+ "environment-portable — tool_…/agent_…/flow_… IDs belong to one account/environment. Use " \
123
+ "builtin:/platform:/mcp: references, or reference a saved resource by name — tool:<name>, " \
124
+ "agent:<name>, or flow:<name> instead."
125
+ end
126
+
127
+ @content_hash = self.class.content_hash_for(to_h)
128
+ freeze
129
+ end
130
+
131
+ # The wire definition sent to `POST /v1/agents/ensure`.
132
+ #
133
+ # @return [Hash{String => Object}]
134
+ def to_h
135
+ wire = { "name" => name }
136
+ wire["description"] = description unless description.nil?
137
+ wire["icon"] = icon unless icon.nil?
138
+ wire["config"] = config
139
+ wire
140
+ end
141
+
142
+ # The canonical normalized form the content hash is computed over: keys
143
+ # ordered as `JSON.stringify` emits them, nil entries dropped, empty
144
+ # description/icon dropped, config restricted to {CONFIG_KEYS} (unknown
145
+ # keys excluded). Byte-identical to the TypeScript SDK's
146
+ # `normalizeAgentDefinition` once serialized.
147
+ #
148
+ # @param definition [Hash] a wire-shaped definition
149
+ # (`{"name", "description"?, "icon"?, "config"?}`), such as the one
150
+ # {Runtype::Client#pull_agent} returns.
151
+ # @return [Hash{String => Object}]
152
+ def self.normalize(definition)
153
+ wire = definition.to_h { |key, value| [key.to_s, value] }
154
+ description = wire["description"]
155
+ icon = wire["icon"]
156
+ raw_config = wire["config"].is_a?(Hash) ? wire["config"].to_h { |key, value| [key.to_s, value] } : {}
157
+
158
+ canonical = { "name" => wire["name"] }
159
+ canonical["description"] = description if description.is_a?(String) && !description.empty?
160
+ canonical["icon"] = icon if icon.is_a?(String) && !icon.empty?
161
+ canonical["config"] = CONFIG_KEYS.sort.each_with_object({}) do |key, out|
162
+ value = raw_config[key]
163
+ out[key] = normalize_value(value) unless value.nil?
164
+ end
165
+ canonical
166
+ end
167
+
168
+ # SHA-256 (hex) over the canonical normalized form of a wire-shaped
169
+ # definition. Matches the hash the API returns for the same definition.
170
+ #
171
+ # @param definition [Hash] see {.normalize}.
172
+ # @return [String]
173
+ def self.content_hash_for(definition)
174
+ Digest::SHA256.hexdigest(Runtype::CanonicalJSON.generate(normalize(definition)))
175
+ end
176
+
177
+ # Drops nil entries and orders object keys the way V8's `JSON.stringify`
178
+ # emits them — canonical array indexes first in ascending numeric order,
179
+ # then the remaining keys sorted by UTF-16 code units — recursively; arrays
180
+ # keep their order. The TypeScript normalizer sorts keys and then
181
+ # serializes, and serialization applies this order, so hashing the same
182
+ # bytes requires it here.
183
+ #
184
+ # @param value [Object]
185
+ # @return [Object]
186
+ def self.normalize_value(value)
187
+ case value
188
+ when Array
189
+ value.map { |entry| normalize_value(entry) }
190
+ when Hash
191
+ entries = value.to_h { |key, entry| [key.to_s, entry] }
192
+ entries.keys.sort_by { |key| json_key_order(key) }.each_with_object({}) do |key, out|
193
+ out[key] = normalize_value(entries[key]) unless entries[key].nil?
194
+ end
195
+ else
196
+ value
197
+ end
198
+ end
199
+ private_class_method :normalize_value
200
+
201
+ # @param key [String]
202
+ # @return [Array] a sort key: `[0, index]` for canonical array indexes,
203
+ # `[1, utf16 code units]` for everything else.
204
+ def self.json_key_order(key)
205
+ if key.match?(/\A(?:0|[1-9]\d*)\z/) && key.to_i <= MAX_ARRAY_INDEX
206
+ [0, key.to_i, []]
207
+ else
208
+ [1, 0, key.encode("UTF-16LE").unpack("v*")]
209
+ end
210
+ end
211
+ private_class_method :json_key_order
212
+
213
+ # @return [Hash{String => Object}] see {.normalize}.
214
+ def normalized
215
+ self.class.normalize(to_h)
216
+ end
217
+
218
+ # @return [Boolean]
219
+ def ==(other)
220
+ other.is_a?(AgentDefinition) && other.name == name && other.description == description &&
221
+ other.icon == icon && other.config == config
222
+ end
223
+ alias eql? ==
224
+
225
+ # @return [Integer]
226
+ def hash
227
+ content_hash.hash
228
+ end
229
+
230
+ # @return [String]
231
+ def inspect
232
+ "#<#{self.class.name} name=#{name.inspect} model=#{config["model"].inspect}>"
233
+ end
234
+
235
+ private
236
+
237
+ # @param key [String, Symbol]
238
+ # @return [String]
239
+ def canonical_key(key)
240
+ string = key.to_s
241
+ SNAKE_CASE_ALIASES.fetch(string, string)
242
+ end
243
+
244
+ # Lifts a wire-shaped `config` Hash into the flat authoring shape, so both
245
+ # spellings validate through the same path.
246
+ #
247
+ # @param fields [Hash{String => Object}]
248
+ # @return [Hash{String => Object}]
249
+ def flatten_wire_shape(fields)
250
+ return fields unless fields["config"].is_a?(Hash)
251
+
252
+ config = fields["config"].to_h { |key, value| [canonical_key(key), value] }
253
+ fields.except("config").merge(config)
254
+ end
255
+
256
+ # @param value [Object]
257
+ # @return [Object] a fresh copy of the structure with String keys throughout.
258
+ def deep_stringify_keys(value)
259
+ case value
260
+ when Hash then value.to_h { |key, entry| [key.to_s, deep_stringify_keys(entry)] }
261
+ when Array then value.map { |entry| deep_stringify_keys(entry) }
262
+ when String then value.dup
263
+ else value
264
+ end
265
+ end
266
+
267
+ # Freezes every Hash, Array and String in a structure the constructor just
268
+ # copied, so the definition that was validated is the one that gets hashed
269
+ # and sent: no caller can reach through {#config} or {#to_h} and slip an
270
+ # account-scoped reference past the constructor checks.
271
+ #
272
+ # @param value [Object]
273
+ # @return [Object] the same value, frozen throughout.
274
+ def deep_freeze(value)
275
+ case value
276
+ when Hash then value.each_value { |entry| deep_freeze(entry) }
277
+ when Array then value.each { |entry| deep_freeze(entry) }
278
+ end
279
+ value.freeze
280
+ end
281
+
282
+ # Collects account-scoped references, which the v1 ensure surface rejects
283
+ # because a definition must converge every environment unchanged.
284
+ # Mirrors `collectNonPortableToolRefs` in the TypeScript SDK.
285
+ #
286
+ # @param config [Hash]
287
+ # @return [Array<String>] JSON-path-ish locations of the offending refs.
288
+ def non_portable_refs(config)
289
+ tools = config["tools"]
290
+ return [] unless tools.is_a?(Hash)
291
+
292
+ found = []
293
+ scan_array = lambda do |value, path|
294
+ next unless value.is_a?(Array)
295
+
296
+ value.each_with_index { |ref, index| found << "#{path}[#{index}]" if account_scoped?(ref) }
297
+ end
298
+ scan_keys = lambda do |value, path|
299
+ next unless value.is_a?(Hash)
300
+
301
+ value.each_key { |key| found << "#{path}.#{key}" if account_scoped?(key) }
302
+ end
303
+
304
+ scan_array.call(tools["toolIds"], "tools.toolIds")
305
+ scan_keys.call(tools["toolConfigs"], "tools.toolConfigs")
306
+ scan_keys.call(tools["perToolLimits"], "tools.perToolLimits")
307
+ scan_array.call(tools.dig("approval", "require"), "tools.approval.require")
308
+ scan_array.call(tools.dig("subagentConfig", "toolPool"), "tools.subagentConfig.toolPool")
309
+ scan_array.call(tools.dig("codeModeConfig", "toolPool"), "tools.codeModeConfig.toolPool")
310
+
311
+ Array(tools["runtimeTools"]).each_with_index do |runtime_tool, index|
312
+ next unless runtime_tool.is_a?(Hash) && runtime_tool["config"].is_a?(Hash)
313
+
314
+ base = "tools.runtimeTools[#{index}].config"
315
+ tool_config = runtime_tool["config"]
316
+ if runtime_tool["toolType"] == "subagent" && tool_config["agentId"].to_s.start_with?("agent_")
317
+ found << "#{base}.agentId"
318
+ elsif runtime_tool["toolType"] == "flow" && tool_config["flowId"].to_s.start_with?("flow_")
319
+ found << "#{base}.flowId"
320
+ end
321
+ end
322
+ found
323
+ end
324
+
325
+ # @param ref [Object]
326
+ # @return [Boolean]
327
+ def account_scoped?(ref)
328
+ ref.is_a?(String) && ref.start_with?("tool_")
329
+ end
330
+ end
331
+ end