agent-client-protocol 0.1.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,314 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "set"
4
+
5
+ module AgentClientProtocol
6
+ module Types
7
+ class Base
8
+ class << self
9
+ attr_reader :definition_name, :schema, :properties, :required_keys, :ruby_property_map, :ruby_to_json_property_map,
10
+ :additional_properties, :property_schemas, :unstable
11
+
12
+ def configure(definition_name:, schema:, unstable: false)
13
+ @definition_name = definition_name
14
+ @schema = schema
15
+ @unstable = unstable
16
+
17
+ @properties = (schema["properties"] || {}).keys.freeze
18
+ @property_schemas = (schema["properties"] || {}).freeze
19
+ @required_keys = (schema["required"] || []).freeze
20
+ @additional_properties = schema["additionalProperties"]
21
+ @ruby_property_map = build_ruby_property_map(@properties)
22
+ @ruby_to_json_property_map = @ruby_property_map.each_with_object({}) do |(json_key, ruby_key), acc|
23
+ acc[ruby_key.to_s] = json_key
24
+ end.freeze
25
+
26
+ define_property_readers!
27
+ freeze
28
+ end
29
+
30
+ def coerce(value)
31
+ return value if value.is_a?(self)
32
+
33
+ new(value)
34
+ end
35
+
36
+ def build(attributes = nil, **kwargs)
37
+ if attributes && !kwargs.empty?
38
+ raise ArgumentError, "pass either attributes Hash or keyword args, not both"
39
+ end
40
+
41
+ payload = attributes || kwargs
42
+ new(payload)
43
+ end
44
+
45
+ private
46
+
47
+ def build_ruby_property_map(property_names)
48
+ reserved = instance_methods.map(&:to_s).to_set
49
+ mapping = {}
50
+
51
+ property_names.each do |json_key|
52
+ candidate = ruby_identifier_for(json_key)
53
+ candidate = "field" if candidate.empty?
54
+ candidate = "_#{candidate}" if candidate.match?(/\A\d/)
55
+
56
+ while reserved.include?(candidate)
57
+ candidate = "#{candidate}_field"
58
+ end
59
+
60
+ reserved.add(candidate)
61
+ mapping[json_key] = candidate.to_sym
62
+ end
63
+
64
+ mapping.freeze
65
+ end
66
+
67
+ def ruby_identifier_for(json_key)
68
+ sanitized = json_key.to_s.sub(/\A_+/, "")
69
+ step_one = sanitized.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
70
+ step_two = step_one.gsub(/([a-z\d])([A-Z])/, '\1_\2')
71
+ step_two.tr("-", "_").downcase
72
+ end
73
+
74
+ def define_property_readers!
75
+ @ruby_property_map.each do |json_key, ruby_key|
76
+ next if method_defined?(ruby_key)
77
+
78
+ define_method(ruby_key) do
79
+ @attributes[json_key]
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ def initialize(attributes = nil)
86
+ unless attributes.nil? || attributes.is_a?(Hash)
87
+ raise ArgumentError, "#{self.class.definition_name} expects a Hash payload"
88
+ end
89
+
90
+ attributes ||= {}
91
+ normalized = normalize_attributes(attributes)
92
+
93
+ missing = self.class.required_keys.reject { |key| normalized.key?(key) }
94
+ unless missing.empty?
95
+ raise ::AgentClientProtocol::Error.invalid_params(
96
+ "#{self.class.definition_name} missing required keys: #{missing.join(', ')}"
97
+ )
98
+ end
99
+
100
+ if self.class.additional_properties == false
101
+ unknown_keys = normalized.keys - self.class.properties
102
+ unless unknown_keys.empty?
103
+ raise ::AgentClientProtocol::Error.invalid_params(
104
+ "#{self.class.definition_name} unknown keys: #{unknown_keys.join(', ')}"
105
+ )
106
+ end
107
+ end
108
+
109
+ @attributes = normalized.freeze
110
+ freeze
111
+ end
112
+
113
+ def [](key)
114
+ json_key = self.class.ruby_to_json_property_map[key.to_s] || key.to_s
115
+ @attributes[json_key]
116
+ end
117
+
118
+ def fetch(key, *args)
119
+ json_key = self.class.ruby_to_json_property_map[key.to_s] || key.to_s
120
+ @attributes.fetch(json_key, *args)
121
+ end
122
+
123
+ def key?(key)
124
+ json_key = self.class.ruby_to_json_property_map[key.to_s] || key.to_s
125
+ @attributes.key?(json_key)
126
+ end
127
+
128
+ def to_h
129
+ deep_to_h(@attributes)
130
+ end
131
+
132
+ def to_json(*args)
133
+ to_h.to_json(*args)
134
+ end
135
+
136
+ def ==(other)
137
+ other.is_a?(self.class) && other.to_h == to_h
138
+ end
139
+
140
+ alias eql? ==
141
+
142
+ def hash
143
+ [self.class, @attributes].hash
144
+ end
145
+
146
+ private
147
+
148
+ def normalize_attributes(attributes)
149
+ attributes.each_with_object({}) do |(k, v), acc|
150
+ key = normalize_input_key(k)
151
+ schema = self.class.property_schemas[key]
152
+ acc[key] = normalize_input_value(v, schema)
153
+ end
154
+ end
155
+
156
+ def normalize_input_key(key)
157
+ key_str = key.to_s
158
+ return key_str if self.class.properties.include?(key_str)
159
+
160
+ self.class.ruby_to_json_property_map[key_str] || key_str
161
+ end
162
+
163
+ def normalize_input_value(value, schema = nil)
164
+ return self.class.coerce_for_schema(value, schema, unstable: self.class.unstable) unless schema.nil?
165
+
166
+ if value.is_a?(Base) || value.is_a?(Scalar)
167
+ value
168
+ elsif value.is_a?(Hash)
169
+ value.transform_keys(&:to_s).transform_values { |nested| normalize_input_value(nested) }
170
+ elsif value.is_a?(Array)
171
+ value.map { |item| normalize_input_value(item) }
172
+ else
173
+ value
174
+ end
175
+ end
176
+
177
+ def deep_to_h(value)
178
+ case value
179
+ when Base
180
+ value.to_h
181
+ when Scalar
182
+ value.to_h
183
+ when Hash
184
+ value.each_with_object({}) do |(k, v), acc|
185
+ acc[k] = deep_to_h(v)
186
+ end
187
+ when Array
188
+ value.map { |item| deep_to_h(item) }
189
+ else
190
+ value
191
+ end
192
+ end
193
+
194
+ class << self
195
+ def coerce_for_schema(value, schema, unstable:)
196
+ return value if schema.nil?
197
+
198
+ if schema["$ref"]
199
+ return coerce_ref(value, schema["$ref"], unstable: unstable)
200
+ end
201
+
202
+ if schema["type"] == "array" && value.is_a?(Array)
203
+ item_schema = schema["items"]
204
+ return value.map { |item| coerce_for_schema(item, item_schema, unstable: unstable) }
205
+ end
206
+
207
+ if schema["type"] == "object" && value.is_a?(Hash)
208
+ inline_properties = schema["properties"] || {}
209
+ normalized = value.transform_keys(&:to_s)
210
+ return normalized.each_with_object({}) do |(k, v), acc|
211
+ acc[k] = coerce_for_schema(v, inline_properties[k], unstable: unstable)
212
+ end
213
+ end
214
+
215
+ if schema["anyOf"].is_a?(Array)
216
+ return coerce_union(value, schema["anyOf"], unstable: unstable)
217
+ end
218
+
219
+ if schema["oneOf"].is_a?(Array)
220
+ return coerce_union(value, schema["oneOf"], unstable: unstable)
221
+ end
222
+
223
+ if schema["allOf"].is_a?(Array)
224
+ return schema["allOf"].reduce(value) do |memo, sub_schema|
225
+ coerce_for_schema(memo, sub_schema, unstable: unstable)
226
+ end
227
+ end
228
+
229
+ value
230
+ end
231
+
232
+ private
233
+
234
+ def coerce_ref(value, ref, unstable:)
235
+ definition_name = parse_ref_definition(ref)
236
+ return value if definition_name.nil?
237
+
238
+ klass = ::AgentClientProtocol::TypeRegistry.fetch(definition_name, unstable: unstable)
239
+ return value if klass.nil?
240
+
241
+ klass.coerce(value)
242
+ end
243
+
244
+ def coerce_union(value, schemas, unstable:)
245
+ return nil if value.nil? && schemas.any? { |s| s["type"] == "null" }
246
+
247
+ schemas.each do |sub_schema|
248
+ next if sub_schema["type"] == "null"
249
+
250
+ coerced = coerce_for_schema(value, sub_schema, unstable: unstable)
251
+ return coerced unless coerced.equal?(value)
252
+ rescue StandardError
253
+ next
254
+ end
255
+
256
+ value
257
+ end
258
+
259
+ def parse_ref_definition(ref)
260
+ match = ref.match(%r{\A#/\$defs/([^/]+)\z})
261
+ return nil if match.nil?
262
+
263
+ match[1]
264
+ end
265
+ end
266
+ end
267
+
268
+ class Scalar
269
+ class << self
270
+ attr_reader :definition_name, :schema, :unstable, :coercer
271
+
272
+ def configure(definition_name:, schema:, unstable: false, coercer: nil)
273
+ @definition_name = definition_name
274
+ @schema = schema
275
+ @unstable = unstable
276
+ @coercer = coercer
277
+ freeze
278
+ end
279
+
280
+ def coerce(value)
281
+ return value if value.is_a?(self)
282
+
283
+ normalized = coercer.nil? ? value : coercer.call(value)
284
+ new(normalized)
285
+ end
286
+ end
287
+
288
+ attr_reader :value
289
+
290
+ def initialize(value)
291
+ @value = value
292
+ freeze
293
+ end
294
+
295
+ def to_h
296
+ value
297
+ end
298
+
299
+ def to_json(*args)
300
+ value.to_json(*args)
301
+ end
302
+
303
+ def ==(other)
304
+ other.is_a?(self.class) && other.value == value
305
+ end
306
+
307
+ alias eql? ==
308
+
309
+ def hash
310
+ [self.class, value].hash
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "types/base"
4
+
5
+ module AgentClientProtocol
6
+ module Types
7
+ end
8
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentClientProtocol
4
+ class Validator
5
+ class ValidationError < StandardError
6
+ attr_reader :path
7
+
8
+ def initialize(path, message)
9
+ @path = path
10
+ super(message)
11
+ end
12
+ end
13
+
14
+ class << self
15
+ def validate(definition_name, payload, unstable: false)
16
+ definitions = SchemaRegistry.defs(unstable: unstable)
17
+ definition_key = definition_name.to_s
18
+ schema = definitions.fetch(definition_key) do
19
+ raise ValidationError.new("$", "unknown schema definition #{definition_key}")
20
+ end
21
+
22
+ validate_schema(schema, payload, definitions, "$")
23
+ true
24
+ rescue ValidationError => e
25
+ raise Error.invalid_params("invalid payload for #{definition_name} at #{e.path}: #{e.message}")
26
+ end
27
+
28
+ private
29
+
30
+ def validate_schema(schema, value, definitions, path)
31
+ return if schema.nil? || schema == true
32
+ raise ValidationError.new(path, "value is not allowed") if schema == false
33
+ raise ValidationError.new(path, "invalid schema node") unless schema.is_a?(Hash)
34
+
35
+ if (ref = schema["$ref"])
36
+ validate_schema(resolve_ref(ref, definitions, path), value, definitions, path)
37
+ end
38
+
39
+ return if protocol_version_legacy_string?(schema, value, definitions)
40
+
41
+ validate_type(schema["type"], value, path) if schema.key?("type")
42
+ validate_format(schema["format"], value, path) if schema.key?("format")
43
+ validate_enum(schema["enum"], value, path) if schema.key?("enum")
44
+ validate_const(schema["const"], value, path) if schema.key?("const")
45
+ validate_numeric_bounds(schema, value, path)
46
+ validate_string_bounds(schema, value, path)
47
+ validate_array_bounds(schema, value, path)
48
+
49
+ validate_object(schema, value, definitions, path)
50
+ validate_items(schema["items"], value, definitions, path) if schema.key?("items")
51
+
52
+ validate_all_of(schema["allOf"], value, definitions, path) if schema["allOf"].is_a?(Array)
53
+ validate_any_of(schema["anyOf"], value, definitions, path) if schema["anyOf"].is_a?(Array)
54
+ validate_one_of(schema["oneOf"], value, definitions, path) if schema["oneOf"].is_a?(Array)
55
+ end
56
+
57
+ def resolve_ref(ref, definitions, path)
58
+ match = ref.to_s.match(%r{\A#/\$defs/([^/]+)\z})
59
+ raise ValidationError.new(path, "unsupported $ref #{ref.inspect}") if match.nil?
60
+
61
+ definition_key = match[1]
62
+ definitions.fetch(definition_key) do
63
+ raise ValidationError.new(path, "unknown $ref #{ref}")
64
+ end
65
+ end
66
+
67
+ def validate_type(type_spec, value, path)
68
+ types = Array(type_spec).map(&:to_s)
69
+ return if types.any? { |type| type_match?(type, value) }
70
+
71
+ raise ValidationError.new(path, "expected #{types.join(' or ')}, got #{json_type(value)}")
72
+ end
73
+
74
+ def type_match?(type, value)
75
+ case type
76
+ when "null"
77
+ value.nil?
78
+ when "boolean"
79
+ value == true || value == false
80
+ when "string"
81
+ value.is_a?(String)
82
+ when "integer"
83
+ value.is_a?(Integer)
84
+ when "number"
85
+ value.is_a?(Numeric)
86
+ when "object"
87
+ value.is_a?(Hash)
88
+ when "array"
89
+ value.is_a?(Array)
90
+ else
91
+ false
92
+ end
93
+ end
94
+
95
+ def json_type(value)
96
+ case value
97
+ when nil
98
+ "null"
99
+ when Hash
100
+ "object"
101
+ when Array
102
+ "array"
103
+ when String
104
+ "string"
105
+ when Integer
106
+ "integer"
107
+ when Numeric
108
+ "number"
109
+ when TrueClass, FalseClass
110
+ "boolean"
111
+ else
112
+ value.class.name
113
+ end
114
+ end
115
+
116
+ def validate_enum(options, value, path)
117
+ return if options.include?(value)
118
+
119
+ raise ValidationError.new(path, "must be one of #{options.inspect}")
120
+ end
121
+
122
+ def validate_const(expected, value, path)
123
+ return if value == expected
124
+
125
+ raise ValidationError.new(path, "must be #{expected.inspect}")
126
+ end
127
+
128
+ def validate_format(format, value, path)
129
+ case format
130
+ when "int32"
131
+ validate_integer_range(value, path, -(2**31), (2**31) - 1)
132
+ when "uint32"
133
+ validate_integer_range(value, path, 0, (2**32) - 1)
134
+ when "int64"
135
+ validate_integer_range(value, path, -(2**63), (2**63) - 1)
136
+ when "uint64"
137
+ validate_integer_range(value, path, 0, (2**64) - 1)
138
+ when "uint16"
139
+ validate_integer_range(value, path, 0, 65_535)
140
+ when "double"
141
+ unless value.is_a?(Numeric)
142
+ raise ValidationError.new(path, "must be a number for format double")
143
+ end
144
+ end
145
+ end
146
+
147
+ def validate_integer_range(value, path, min, max)
148
+ return unless value.is_a?(Integer)
149
+ return if value >= min && value <= max
150
+
151
+ raise ValidationError.new(path, "must be between #{min} and #{max}")
152
+ end
153
+
154
+ def validate_numeric_bounds(schema, value, path)
155
+ return unless value.is_a?(Numeric)
156
+
157
+ if schema.key?("minimum") && value < schema["minimum"]
158
+ raise ValidationError.new(path, "must be >= #{schema['minimum']}")
159
+ end
160
+ if schema.key?("maximum") && value > schema["maximum"]
161
+ raise ValidationError.new(path, "must be <= #{schema['maximum']}")
162
+ end
163
+ end
164
+
165
+ def validate_string_bounds(schema, value, path)
166
+ return unless value.is_a?(String)
167
+
168
+ if schema.key?("minLength") && value.length < schema["minLength"]
169
+ raise ValidationError.new(path, "length must be >= #{schema['minLength']}")
170
+ end
171
+ if schema.key?("maxLength") && value.length > schema["maxLength"]
172
+ raise ValidationError.new(path, "length must be <= #{schema['maxLength']}")
173
+ end
174
+ end
175
+
176
+ def validate_array_bounds(schema, value, path)
177
+ return unless value.is_a?(Array)
178
+
179
+ if schema.key?("minItems") && value.length < schema["minItems"]
180
+ raise ValidationError.new(path, "item count must be >= #{schema['minItems']}")
181
+ end
182
+ if schema.key?("maxItems") && value.length > schema["maxItems"]
183
+ raise ValidationError.new(path, "item count must be <= #{schema['maxItems']}")
184
+ end
185
+ end
186
+
187
+ def validate_object(schema, value, definitions, path)
188
+ return unless value.is_a?(Hash)
189
+
190
+ required = Array(schema["required"])
191
+ properties = schema["properties"].is_a?(Hash) ? schema["properties"] : {}
192
+ additional = schema.key?("additionalProperties") ? schema["additionalProperties"] : true
193
+ object = value.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
194
+
195
+ required.each do |key|
196
+ next if object.key?(key)
197
+
198
+ raise ValidationError.new(path_for_key(path, key), "is required")
199
+ end
200
+
201
+ object.each do |key, nested_value|
202
+ nested_path = path_for_key(path, key)
203
+ if properties.key?(key)
204
+ validate_schema(properties[key], nested_value, definitions, nested_path)
205
+ next
206
+ end
207
+
208
+ if additional == false
209
+ raise ValidationError.new(nested_path, "additional property is not allowed")
210
+ end
211
+
212
+ validate_schema(additional, nested_value, definitions, nested_path) if additional.is_a?(Hash)
213
+ end
214
+ end
215
+
216
+ def validate_items(items_schema, value, definitions, path)
217
+ return unless value.is_a?(Array)
218
+
219
+ case items_schema
220
+ when Array
221
+ items_schema.each_with_index do |item_schema, index|
222
+ break if index >= value.length
223
+
224
+ validate_schema(item_schema, value[index], definitions, "#{path}[#{index}]")
225
+ end
226
+ else
227
+ value.each_with_index do |item, index|
228
+ validate_schema(items_schema, item, definitions, "#{path}[#{index}]")
229
+ end
230
+ end
231
+ end
232
+
233
+ def validate_all_of(schemas, value, definitions, path)
234
+ schemas.each do |sub_schema|
235
+ validate_schema(sub_schema, value, definitions, path)
236
+ end
237
+ end
238
+
239
+ def validate_any_of(schemas, value, definitions, path)
240
+ errors = []
241
+ schemas.each do |sub_schema|
242
+ validate_schema(sub_schema, value, definitions, path)
243
+ return
244
+ rescue ValidationError => e
245
+ errors << e
246
+ end
247
+
248
+ closest = closest_error(errors, path)
249
+ raise ValidationError.new(
250
+ path,
251
+ "must match anyOf (closest mismatch at #{closest.path}: #{closest.message})"
252
+ )
253
+ end
254
+
255
+ def validate_one_of(schemas, value, definitions, path)
256
+ matches = 0
257
+ errors = []
258
+
259
+ schemas.each do |sub_schema|
260
+ validate_schema(sub_schema, value, definitions, path)
261
+ matches += 1
262
+ rescue ValidationError => e
263
+ errors << e
264
+ end
265
+
266
+ return if matches == 1
267
+
268
+ if matches.zero?
269
+ closest = closest_error(errors, path)
270
+ raise ValidationError.new(
271
+ path,
272
+ "must match exactly one schema in oneOf (closest mismatch at #{closest.path}: #{closest.message})"
273
+ )
274
+ end
275
+
276
+ raise ValidationError.new(path, "must match exactly one schema in oneOf (matched #{matches})")
277
+ end
278
+
279
+ def closest_error(errors, path)
280
+ errors.max_by { |error| error.path.length } || ValidationError.new(path, "is invalid")
281
+ end
282
+
283
+ def path_for_key(path, key)
284
+ key = key.to_s
285
+ return "#{path}.#{key}" if key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
286
+
287
+ "#{path}[#{key.inspect}]"
288
+ end
289
+
290
+ def protocol_version_legacy_string?(schema, value, definitions)
291
+ return false unless value.is_a?(String)
292
+
293
+ protocol_version_schema = definitions["ProtocolVersion"]
294
+ return false if protocol_version_schema.nil?
295
+
296
+ schema.equal?(protocol_version_schema) || schema == protocol_version_schema
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentClientProtocol
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "agent_client_protocol/version"
4
+ require_relative "agent_client_protocol/protocol_version"
5
+ require_relative "agent_client_protocol/error"
6
+ require_relative "agent_client_protocol/schema_registry"
7
+ require_relative "agent_client_protocol/methods"
8
+ require_relative "agent_client_protocol/types"
9
+ require_relative "agent_client_protocol/type_registry"
10
+ require_relative "agent_client_protocol/validator"
11
+ require_relative "agent_client_protocol/rpc"
12
+ require_relative "agent_client_protocol/decoder"
13
+ require_relative "agent_client_protocol/codec"
14
+ require_relative "agent_client_protocol/constants"
15
+
16
+ module AgentClientProtocol
17
+ AGENT_METHOD_NAMES = Methods.agent.freeze
18
+ CLIENT_METHOD_NAMES = Methods.client.freeze
19
+
20
+ UNSTABLE_AGENT_METHOD_NAMES = Methods.agent(unstable: true).freeze
21
+ UNSTABLE_CLIENT_METHOD_NAMES = Methods.client(unstable: true).freeze
22
+ PROTOCOL_METHOD_NAMES = Methods.protocol(unstable: true).freeze
23
+
24
+ module_function
25
+
26
+ def type_for(definition_name, unstable: false)
27
+ TypeRegistry.fetch(definition_name, unstable: unstable)
28
+ end
29
+
30
+ def build_typed(definition_name, payload, unstable: false)
31
+ TypeRegistry.build(definition_name, payload, unstable: unstable)
32
+ end
33
+
34
+ def validate(definition_name, payload, unstable: false)
35
+ Validator.validate(definition_name, payload, unstable: unstable)
36
+ end
37
+
38
+ def codec(side:, unstable: false, validate_schema: true)
39
+ Codec.new(side: side, unstable: unstable, validate_schema: validate_schema)
40
+ end
41
+ end
data/schema/meta.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "agentMethods": {
3
+ "authenticate": "authenticate",
4
+ "initialize": "initialize",
5
+ "session_cancel": "session/cancel",
6
+ "session_load": "session/load",
7
+ "session_new": "session/new",
8
+ "session_prompt": "session/prompt",
9
+ "session_set_config_option": "session/set_config_option",
10
+ "session_set_mode": "session/set_mode"
11
+ },
12
+ "clientMethods": {
13
+ "fs_read_text_file": "fs/read_text_file",
14
+ "fs_write_text_file": "fs/write_text_file",
15
+ "session_request_permission": "session/request_permission",
16
+ "session_update": "session/update",
17
+ "terminal_create": "terminal/create",
18
+ "terminal_kill": "terminal/kill",
19
+ "terminal_output": "terminal/output",
20
+ "terminal_release": "terminal/release",
21
+ "terminal_wait_for_exit": "terminal/wait_for_exit"
22
+ },
23
+ "version": 1
24
+ }