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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 14f06d191353192fea8029429fafaaf84fdc1237a0798fca43b2eee1125d1f09
|
|
4
|
+
data.tar.gz: 7b3565f5f24b5c5c427bf373b11e084fd8ae4eb3c39caae4ea34e7840174022e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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: {
|
|
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: {
|
|
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: "
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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
|
data/generated/lib/runtype/model_configs/types/post_v1model_configs_request_settings_custom_model.rb
CHANGED
|
@@ -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
|
data/generated/lib/runtype.rb
CHANGED
|
@@ -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
|