simple_acp 0.0.1

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.
Files changed (80) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/CHANGELOG.md +5 -0
  4. data/COMMITS.md +196 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +385 -0
  7. data/Rakefile +13 -0
  8. data/docs/api/client-base.md +383 -0
  9. data/docs/api/index.md +159 -0
  10. data/docs/api/models.md +286 -0
  11. data/docs/api/server-base.md +379 -0
  12. data/docs/api/storage.md +347 -0
  13. data/docs/assets/images/simple_acp.jpg +0 -0
  14. data/docs/client/index.md +279 -0
  15. data/docs/client/sessions.md +324 -0
  16. data/docs/client/streaming.md +345 -0
  17. data/docs/client/sync-async.md +308 -0
  18. data/docs/core-concepts/agents.md +253 -0
  19. data/docs/core-concepts/events.md +337 -0
  20. data/docs/core-concepts/index.md +147 -0
  21. data/docs/core-concepts/messages.md +211 -0
  22. data/docs/core-concepts/runs.md +278 -0
  23. data/docs/core-concepts/sessions.md +281 -0
  24. data/docs/examples.md +659 -0
  25. data/docs/getting-started/configuration.md +166 -0
  26. data/docs/getting-started/index.md +62 -0
  27. data/docs/getting-started/installation.md +95 -0
  28. data/docs/getting-started/quick-start.md +189 -0
  29. data/docs/index.md +119 -0
  30. data/docs/server/creating-agents.md +360 -0
  31. data/docs/server/http-endpoints.md +411 -0
  32. data/docs/server/index.md +218 -0
  33. data/docs/server/multi-turn.md +329 -0
  34. data/docs/server/streaming.md +315 -0
  35. data/docs/storage/custom.md +414 -0
  36. data/docs/storage/index.md +176 -0
  37. data/docs/storage/memory.md +198 -0
  38. data/docs/storage/postgresql.md +350 -0
  39. data/docs/storage/redis.md +287 -0
  40. data/examples/01_basic/client.rb +88 -0
  41. data/examples/01_basic/server.rb +100 -0
  42. data/examples/02_async_execution/client.rb +107 -0
  43. data/examples/02_async_execution/server.rb +56 -0
  44. data/examples/03_run_management/client.rb +115 -0
  45. data/examples/03_run_management/server.rb +84 -0
  46. data/examples/04_rich_messages/client.rb +160 -0
  47. data/examples/04_rich_messages/server.rb +180 -0
  48. data/examples/05_await_resume/client.rb +164 -0
  49. data/examples/05_await_resume/server.rb +114 -0
  50. data/examples/06_agent_metadata/client.rb +188 -0
  51. data/examples/06_agent_metadata/server.rb +192 -0
  52. data/examples/README.md +252 -0
  53. data/examples/run_demo.sh +137 -0
  54. data/lib/simple_acp/client/base.rb +448 -0
  55. data/lib/simple_acp/client/sse.rb +141 -0
  56. data/lib/simple_acp/models/agent_manifest.rb +129 -0
  57. data/lib/simple_acp/models/await.rb +123 -0
  58. data/lib/simple_acp/models/base.rb +147 -0
  59. data/lib/simple_acp/models/errors.rb +102 -0
  60. data/lib/simple_acp/models/events.rb +256 -0
  61. data/lib/simple_acp/models/message.rb +235 -0
  62. data/lib/simple_acp/models/message_part.rb +225 -0
  63. data/lib/simple_acp/models/metadata.rb +161 -0
  64. data/lib/simple_acp/models/run.rb +298 -0
  65. data/lib/simple_acp/models/session.rb +137 -0
  66. data/lib/simple_acp/models/types.rb +210 -0
  67. data/lib/simple_acp/server/agent.rb +116 -0
  68. data/lib/simple_acp/server/app.rb +264 -0
  69. data/lib/simple_acp/server/base.rb +510 -0
  70. data/lib/simple_acp/server/context.rb +210 -0
  71. data/lib/simple_acp/server/falcon_runner.rb +61 -0
  72. data/lib/simple_acp/storage/base.rb +129 -0
  73. data/lib/simple_acp/storage/memory.rb +108 -0
  74. data/lib/simple_acp/storage/postgresql.rb +233 -0
  75. data/lib/simple_acp/storage/redis.rb +178 -0
  76. data/lib/simple_acp/version.rb +5 -0
  77. data/lib/simple_acp.rb +91 -0
  78. data/mkdocs.yml +152 -0
  79. data/sig/simple_acp.rbs +4 -0
  80. metadata +418 -0
@@ -0,0 +1,448 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module SimpleAcp
7
+ module Client
8
+ # HTTP client for communicating with ACP servers.
9
+ #
10
+ # Provides methods for agent discovery, run execution (sync, async, stream),
11
+ # run management, and session handling.
12
+ #
13
+ # @example Basic usage
14
+ # client = SimpleAcp::Client::Base.new(base_url: "http://localhost:8000")
15
+ # run = client.run_sync(agent: "echo", input: "Hello!")
16
+ # puts run.output.first.text_content
17
+ #
18
+ # @example Streaming
19
+ # client.run_stream(agent: "echo", input: "Hello") do |event|
20
+ # case event
21
+ # when Models::MessagePartEvent
22
+ # print event.part.content
23
+ # end
24
+ # end
25
+ #
26
+ # @example With session
27
+ # client.use_session("my-session-id")
28
+ # client.run_sync(agent: "counter", input: "increment")
29
+ class Base
30
+ # @return [String] the base URL of the ACP server
31
+ attr_reader :base_url
32
+
33
+ # Create a new ACP client.
34
+ #
35
+ # @param base_url [String] the base URL of the ACP server
36
+ # @param options [Hash] additional options
37
+ # @option options [Hash] :headers custom HTTP headers
38
+ # @option options [Array] :auth authentication credentials for Faraday
39
+ # @option options [Integer] :timeout request timeout in seconds (default: 30)
40
+ # @option options [Integer] :open_timeout connection timeout in seconds (default: 10)
41
+ def initialize(base_url:, **options)
42
+ @base_url = base_url.chomp("/")
43
+ @options = options
44
+ @session_id = nil
45
+ @connection = build_connection
46
+ end
47
+
48
+ # Check if the server is reachable.
49
+ #
50
+ # @return [Boolean] true if server responds to ping
51
+ def ping
52
+ response = @connection.get("/ping")
53
+ handle_response(response)
54
+ true
55
+ rescue StandardError
56
+ false
57
+ end
58
+
59
+ # List available agents on the server.
60
+ #
61
+ # @param limit [Integer] maximum number of agents to return (default: 10)
62
+ # @param offset [Integer] number of agents to skip (default: 0)
63
+ # @return [Models::AgentListResponse] list of agent manifests
64
+ def agents(limit: 10, offset: 0)
65
+ response = @connection.get("/agents") do |req|
66
+ req.params["limit"] = limit
67
+ req.params["offset"] = offset
68
+ end
69
+
70
+ data = handle_response(response)
71
+ Models::AgentListResponse.from_hash(data)
72
+ end
73
+
74
+ # Get a specific agent's manifest.
75
+ #
76
+ # @param name [String] the agent name
77
+ # @return [Models::AgentManifest] the agent's manifest
78
+ # @raise [NotFoundError] if the agent does not exist
79
+ def agent(name)
80
+ response = @connection.get("/agents/#{name}")
81
+ data = handle_response(response)
82
+ Models::AgentManifest.from_hash(data)
83
+ end
84
+
85
+ # Run an agent synchronously, blocking until completion.
86
+ #
87
+ # @param agent [String] the agent name
88
+ # @param input [Array<Models::Message>, Models::Message, String] input messages
89
+ # @param session_id [String, nil] optional session ID
90
+ # @return [Models::Run] the completed run with output
91
+ # @raise [NotFoundError] if the agent does not exist
92
+ def run_sync(agent:, input:, session_id: nil)
93
+ input_messages = normalize_input(input)
94
+
95
+ response = @connection.post("/runs") do |req|
96
+ req.headers["Content-Type"] = "application/json"
97
+ req.body = {
98
+ agent_name: agent,
99
+ input: input_messages.map(&:to_h),
100
+ mode: Models::Types::RunMode::SYNC,
101
+ session_id: session_id || @session_id
102
+ }.to_json
103
+ end
104
+
105
+ data = handle_response(response)
106
+ run = Models::Run.from_hash(data)
107
+ @session_id = run.session_id if run.session_id
108
+ run
109
+ end
110
+
111
+ # Run an agent asynchronously, returning immediately.
112
+ #
113
+ # Use {#run_status} or {#wait_for_run} to check completion.
114
+ #
115
+ # @param agent [String] the agent name
116
+ # @param input [Array<Models::Message>, Models::Message, String] input messages
117
+ # @param session_id [String, nil] optional session ID
118
+ # @return [Models::Run] the run (status will be :created or :in_progress)
119
+ # @raise [NotFoundError] if the agent does not exist
120
+ def run_async(agent:, input:, session_id: nil)
121
+ input_messages = normalize_input(input)
122
+
123
+ response = @connection.post("/runs") do |req|
124
+ req.headers["Content-Type"] = "application/json"
125
+ req.body = {
126
+ agent_name: agent,
127
+ input: input_messages.map(&:to_h),
128
+ mode: Models::Types::RunMode::ASYNC,
129
+ session_id: session_id || @session_id
130
+ }.to_json
131
+ end
132
+
133
+ data = handle_response(response)
134
+ run = Models::Run.from_hash(data)
135
+ @session_id = run.session_id if run.session_id
136
+ run
137
+ end
138
+
139
+ # Run an agent with streaming response via Server-Sent Events.
140
+ #
141
+ # @param agent [String] the agent name
142
+ # @param input [Array<Models::Message>, Models::Message, String] input messages
143
+ # @param session_id [String, nil] optional session ID
144
+ # @yield [Models::Event] events as they occur
145
+ # @return [Enumerator<Models::Event>] if no block given
146
+ # @raise [NotFoundError] if the agent does not exist
147
+ #
148
+ # @example With block
149
+ # client.run_stream(agent: "echo", input: "Hello") do |event|
150
+ # puts event.class.name
151
+ # end
152
+ #
153
+ # @example As enumerator
154
+ # events = client.run_stream(agent: "echo", input: "Hello")
155
+ # events.each { |e| puts e }
156
+ def run_stream(agent:, input:, session_id: nil, &block)
157
+ input_messages = normalize_input(input)
158
+
159
+ if block_given?
160
+ run_stream_with_block(agent, input_messages, session_id, &block)
161
+ else
162
+ run_stream_enumerable(agent, input_messages, session_id)
163
+ end
164
+ end
165
+
166
+ # Get the current status of a run.
167
+ #
168
+ # @param run_id [String] the run ID
169
+ # @return [Models::Run] the run with current status
170
+ # @raise [NotFoundError] if the run does not exist
171
+ def run_status(run_id)
172
+ response = @connection.get("/runs/#{run_id}")
173
+ data = handle_response(response)
174
+ Models::Run.from_hash(data)
175
+ end
176
+
177
+ # Get events that occurred during a run.
178
+ #
179
+ # @param run_id [String] the run ID
180
+ # @param limit [Integer] maximum number of events to return (default: 100)
181
+ # @param offset [Integer] number of events to skip (default: 0)
182
+ # @return [Array<Models::Event>] list of events
183
+ def run_events(run_id, limit: 100, offset: 0)
184
+ response = @connection.get("/runs/#{run_id}/events") do |req|
185
+ req.params["limit"] = limit
186
+ req.params["offset"] = offset
187
+ end
188
+
189
+ data = handle_response(response)
190
+ (data["events"] || []).map { |e| Models::Events.from_hash(e) }
191
+ end
192
+
193
+ # Cancel a running agent execution.
194
+ #
195
+ # @param run_id [String] the run ID to cancel
196
+ # @return [Models::Run] the cancelled run
197
+ # @raise [NotFoundError] if the run does not exist
198
+ def run_cancel(run_id)
199
+ response = @connection.post("/runs/#{run_id}/cancel")
200
+ data = handle_response(response)
201
+ Models::Run.from_hash(data)
202
+ end
203
+
204
+ # Resume an awaited run synchronously.
205
+ #
206
+ # @param run_id [String] the run ID to resume
207
+ # @param await_resume [Models::AwaitResume] the resume payload with client response
208
+ # @return [Models::Run] the completed run
209
+ # @raise [NotFoundError] if the run does not exist
210
+ # @raise [ValidationError] if the run is not in awaiting state
211
+ def run_resume_sync(run_id:, await_resume:)
212
+ response = @connection.post("/runs/#{run_id}") do |req|
213
+ req.headers["Content-Type"] = "application/json"
214
+ req.body = {
215
+ await_resume: await_resume.to_h,
216
+ mode: Models::Types::RunMode::SYNC
217
+ }.to_json
218
+ end
219
+
220
+ data = handle_response(response)
221
+ Models::Run.from_hash(data)
222
+ end
223
+
224
+ # Resume an awaited run with streaming output.
225
+ #
226
+ # @param run_id [String] the run ID to resume
227
+ # @param await_resume [Models::AwaitResume] the resume payload with client response
228
+ # @yield [Models::Event] events as they occur
229
+ # @return [Enumerator<Models::Event>] if no block given
230
+ # @raise [NotFoundError] if the run does not exist
231
+ # @raise [ValidationError] if the run is not in awaiting state
232
+ def run_resume_stream(run_id:, await_resume:, &block)
233
+ if block_given?
234
+ resume_stream_with_block(run_id, await_resume, &block)
235
+ else
236
+ resume_stream_enumerable(run_id, await_resume)
237
+ end
238
+ end
239
+
240
+ # Get session details.
241
+ #
242
+ # @param session_id [String] the session ID
243
+ # @return [Models::SessionResponse] session information
244
+ # @raise [NotFoundError] if the session does not exist
245
+ def session(session_id)
246
+ response = @connection.get("/session/#{session_id}")
247
+ data = handle_response(response)
248
+ Models::SessionResponse.from_hash(data)
249
+ end
250
+
251
+ # Set a session ID for subsequent requests.
252
+ #
253
+ # @param session_id [String] the session ID to use
254
+ # @return [self] for chaining
255
+ def use_session(session_id)
256
+ @session_id = session_id
257
+ self
258
+ end
259
+
260
+ # Clear the current session ID.
261
+ #
262
+ # @return [self] for chaining
263
+ def clear_session
264
+ @session_id = nil
265
+ self
266
+ end
267
+
268
+ # Poll until a run completes or times out.
269
+ #
270
+ # @param run_id [String] the run ID to wait for
271
+ # @param timeout [Integer] maximum seconds to wait (default: 60)
272
+ # @param interval [Integer] seconds between polls (default: 1)
273
+ # @return [Models::Run] the completed run
274
+ # @raise [Error] if timeout is exceeded
275
+ def wait_for_run(run_id, timeout: 60, interval: 1)
276
+ deadline = Time.now + timeout
277
+
278
+ loop do
279
+ run = run_status(run_id)
280
+ return run if run.terminal?
281
+
282
+ raise SimpleAcp::Error, "Timeout waiting for run to complete" if Time.now > deadline
283
+
284
+ sleep(interval)
285
+ end
286
+ end
287
+
288
+ # Close the HTTP connection.
289
+ #
290
+ # @return [void]
291
+ def close
292
+ @connection.close if @connection.respond_to?(:close)
293
+ end
294
+
295
+ private
296
+
297
+ def build_connection
298
+ Faraday.new(url: @base_url) do |conn|
299
+ conn.headers["Accept"] = "application/json"
300
+ conn.headers["User-Agent"] = "acp-ruby/#{SimpleAcp::VERSION}"
301
+
302
+ # Add any custom headers
303
+ @options[:headers]&.each do |key, value|
304
+ conn.headers[key] = value
305
+ end
306
+
307
+ # Authentication
308
+ if @options[:auth]
309
+ conn.request :authorization, *@options[:auth]
310
+ end
311
+
312
+ # Timeout settings
313
+ conn.options.timeout = @options[:timeout] || 30
314
+ conn.options.open_timeout = @options[:open_timeout] || 10
315
+
316
+ conn.adapter Faraday.default_adapter
317
+ end
318
+ end
319
+
320
+ def handle_response(response)
321
+ case response.status
322
+ when 200..299
323
+ parse_json(response.body)
324
+ when 404
325
+ error_data = parse_json(response.body)
326
+ raise SimpleAcp::NotFoundError, error_data.dig("error", "message") || "Not found"
327
+ when 400
328
+ error_data = parse_json(response.body)
329
+ raise SimpleAcp::ValidationError, error_data.dig("error", "message") || "Invalid request"
330
+ else
331
+ error_data = parse_json(response.body)
332
+ raise SimpleAcp::Error, error_data.dig("error", "message") || "Request failed with status #{response.status}"
333
+ end
334
+ end
335
+
336
+ def parse_json(body)
337
+ return {} if body.nil? || body.empty?
338
+
339
+ JSON.parse(body)
340
+ rescue JSON::ParserError
341
+ {}
342
+ end
343
+
344
+ def normalize_input(input)
345
+ case input
346
+ when Array
347
+ input.map { |i| normalize_message(i) }
348
+ else
349
+ [normalize_message(input)]
350
+ end
351
+ end
352
+
353
+ def normalize_message(msg)
354
+ case msg
355
+ when Models::Message
356
+ msg
357
+ when Models::MessagePart
358
+ Models::Message.user(msg)
359
+ when String
360
+ Models::Message.user(msg)
361
+ when Hash
362
+ Models::Message.from_hash(msg)
363
+ else
364
+ Models::Message.user(msg.to_s)
365
+ end
366
+ end
367
+
368
+ def run_stream_with_block(agent, input_messages, session_id)
369
+ stream_request("/runs", {
370
+ agent_name: agent,
371
+ input: input_messages.map(&:to_h),
372
+ mode: Models::Types::RunMode::STREAM,
373
+ session_id: session_id || @session_id
374
+ }) do |event|
375
+ if event.is_a?(Models::RunCompletedEvent) && event.run&.session_id
376
+ @session_id = event.run.session_id
377
+ end
378
+ yield event
379
+ end
380
+ end
381
+
382
+ def run_stream_enumerable(agent, input_messages, session_id)
383
+ Enumerator.new do |yielder|
384
+ run_stream_with_block(agent, input_messages, session_id) do |event|
385
+ yielder << event
386
+ end
387
+ end
388
+ end
389
+
390
+ def resume_stream_with_block(run_id, await_resume)
391
+ stream_request("/runs/#{run_id}", {
392
+ await_resume: await_resume.to_h,
393
+ mode: Models::Types::RunMode::STREAM
394
+ }) do |event|
395
+ yield event
396
+ end
397
+ end
398
+
399
+ def resume_stream_enumerable(run_id, await_resume)
400
+ Enumerator.new do |yielder|
401
+ resume_stream_with_block(run_id, await_resume) do |event|
402
+ yielder << event
403
+ end
404
+ end
405
+ end
406
+
407
+ def stream_request(path, body)
408
+ uri = URI.join(@base_url, path)
409
+
410
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
411
+ request = Net::HTTP::Post.new(uri)
412
+ request["Content-Type"] = "application/json"
413
+ request["Accept"] = "text/event-stream"
414
+ request["User-Agent"] = "acp-ruby/#{SimpleAcp::VERSION}"
415
+ request.body = body.to_json
416
+
417
+ http.request(request) do |response|
418
+ unless response.is_a?(Net::HTTPSuccess)
419
+ raise SimpleAcp::Error, "Stream request failed with status #{response.code}"
420
+ end
421
+
422
+ parser = SSEParser.new
423
+
424
+ response.read_body do |chunk|
425
+ parser.feed(chunk)
426
+ parser.each_event do |raw_event|
427
+ event = parse_sse_event(raw_event)
428
+ yield event if event
429
+
430
+ # Check for error events
431
+ if event.is_a?(Models::ErrorEvent)
432
+ raise SimpleAcp::RunError, event.error&.message || "Unknown error"
433
+ end
434
+ end
435
+ end
436
+ end
437
+ end
438
+ end
439
+
440
+ def parse_sse_event(raw_event)
441
+ data = JSON.parse(raw_event[:data])
442
+ Models::Events.from_hash(data)
443
+ rescue JSON::ParserError
444
+ nil
445
+ end
446
+ end
447
+ end
448
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleAcp
4
+ module Client
5
+ # Server-Sent Events (SSE) parser for streaming responses.
6
+ #
7
+ # Parses chunked SSE data into structured events following the
8
+ # SSE specification (event, data, id, retry fields).
9
+ class SSEParser
10
+ # @return [Array<Hash>] parsed events with :event and :data keys
11
+ attr_reader :events
12
+
13
+ # Create a new SSE parser.
14
+ def initialize
15
+ @buffer = ""
16
+ @events = []
17
+ @current_event = nil
18
+ @current_data = []
19
+ end
20
+
21
+ # Feed a chunk of data to the parser.
22
+ #
23
+ # @param chunk [String] raw SSE data chunk
24
+ # @return [void]
25
+ def feed(chunk)
26
+ @buffer += chunk
27
+ parse_buffer
28
+ flush_events
29
+ end
30
+
31
+ # Iterate over parsed events, clearing them after yield.
32
+ #
33
+ # @yield [Hash] event with :event (type) and :data (payload) keys
34
+ # @return [void]
35
+ def each_event
36
+ flush_events
37
+ while (event = @events.shift)
38
+ yield event
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def parse_buffer
45
+ while (line_end = @buffer.index("\n"))
46
+ line = @buffer[0...line_end]
47
+ @buffer = @buffer[(line_end + 1)..]
48
+ line = line.chomp("\r")
49
+ process_line(line)
50
+ end
51
+ end
52
+
53
+ def process_line(line)
54
+ if line.empty?
55
+ # Empty line = event boundary
56
+ emit_event
57
+ elsif line.start_with?(":")
58
+ # Comment, ignore
59
+ elsif line.include?(":")
60
+ field, value = line.split(":", 2)
61
+ value = value[1..] if value&.start_with?(" ")
62
+ process_field(field, value || "")
63
+ else
64
+ # Field with no value
65
+ process_field(line, "")
66
+ end
67
+ end
68
+
69
+ def process_field(field, value)
70
+ case field
71
+ when "event"
72
+ @current_event = value
73
+ when "data"
74
+ @current_data << value
75
+ when "id"
76
+ # We don't track IDs currently
77
+ when "retry"
78
+ # We don't handle retry currently
79
+ end
80
+ end
81
+
82
+ def emit_event
83
+ return if @current_data.empty?
84
+
85
+ data = @current_data.join("\n")
86
+ @events << {
87
+ event: @current_event || "message",
88
+ data: data
89
+ }
90
+
91
+ @current_event = nil
92
+ @current_data = []
93
+ end
94
+
95
+ def flush_events
96
+ emit_event unless @current_data.empty?
97
+ end
98
+ end
99
+
100
+ # Enumerable wrapper for streaming SSE responses.
101
+ #
102
+ # Reads from an HTTP response body, parses SSE events,
103
+ # and converts them to ACP event objects.
104
+ class SSEStream
105
+ include Enumerable
106
+
107
+ # Create a new SSE stream reader.
108
+ #
109
+ # @param response [Net::HTTPResponse] the HTTP response with SSE body
110
+ def initialize(response)
111
+ @response = response
112
+ @parser = SSEParser.new
113
+ end
114
+
115
+ # Iterate over ACP events from the stream.
116
+ #
117
+ # @yield [Models::Event] parsed ACP events
118
+ # @return [Enumerator] if no block given
119
+ def each
120
+ return enum_for(:each) unless block_given?
121
+
122
+ @response.body.each do |chunk|
123
+ @parser.feed(chunk)
124
+ @parser.each_event do |raw_event|
125
+ event = parse_acp_event(raw_event)
126
+ yield event if event
127
+ end
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def parse_acp_event(raw_event)
134
+ data = JSON.parse(raw_event[:data])
135
+ Models::Events.from_hash(data)
136
+ rescue JSON::ParserError
137
+ nil
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleAcp
4
+ module Models
5
+ # Describes agent capabilities for discovery.
6
+ #
7
+ # Contains the metadata needed to advertise an agent's capabilities,
8
+ # including what content types it accepts and produces.
9
+ class AgentManifest < Base
10
+ # @!attribute [r] name
11
+ # @return [String] agent name (RFC 1123 DNS label format)
12
+ attribute :name, required: true
13
+
14
+ # @!attribute [r] description
15
+ # @return [String, nil] human-readable description
16
+ attribute :description
17
+
18
+ # @!attribute [r] metadata
19
+ # @return [Metadata, nil] additional metadata
20
+ attribute :metadata
21
+
22
+ # @!attribute [r] input_content_types
23
+ # @return [Array<String>] accepted MIME types (default: ["text/plain"])
24
+ attribute :input_content_types, default: -> { ["text/plain"] }
25
+
26
+ # @!attribute [r] output_content_types
27
+ # @return [Array<String>] produced MIME types (default: ["text/plain"])
28
+ attribute :output_content_types, default: -> { ["text/plain"] }
29
+
30
+ # @!attribute [r] status
31
+ # @return [AgentStatus, nil] status metrics
32
+ attribute :status
33
+
34
+ def initialize(**kwargs)
35
+ super
36
+ @input_content_types ||= ["text/plain"]
37
+ @output_content_types ||= ["text/plain"]
38
+ end
39
+
40
+ # Create from a hash (JSON deserialization).
41
+ #
42
+ # @param hash [Hash, nil] manifest data
43
+ # @return [AgentManifest, nil] the manifest or nil
44
+ def self.from_hash(hash)
45
+ return nil if hash.nil?
46
+
47
+ instance = super
48
+
49
+ if hash["metadata"] || hash[:metadata]
50
+ instance.metadata = Metadata.from_hash(hash["metadata"] || hash[:metadata])
51
+ end
52
+
53
+ if hash["status"] || hash[:status]
54
+ instance.status = AgentStatus.from_hash(hash["status"] || hash[:status])
55
+ end
56
+
57
+ instance
58
+ end
59
+
60
+ # Check if the agent accepts a content type.
61
+ #
62
+ # @param content_type [String] MIME type to check
63
+ # @return [Boolean] true if accepted
64
+ def accepts_content_type?(content_type)
65
+ return true if @input_content_types.include?("*/*")
66
+
67
+ @input_content_types.any? do |accepted|
68
+ matches_content_type?(accepted, content_type)
69
+ end
70
+ end
71
+
72
+ # Check if the agent produces a content type.
73
+ #
74
+ # @param content_type [String] MIME type to check
75
+ # @return [Boolean] true if produced
76
+ def produces_content_type?(content_type)
77
+ return true if @output_content_types.include?("*/*")
78
+
79
+ @output_content_types.any? do |produced|
80
+ matches_content_type?(produced, content_type)
81
+ end
82
+ end
83
+
84
+ # Validate the manifest.
85
+ #
86
+ # @return [Boolean] true if name is valid and content types are non-empty
87
+ def valid?
88
+ return false unless Types.valid_agent_name?(@name)
89
+ return false if @input_content_types.empty?
90
+ return false if @output_content_types.empty?
91
+
92
+ true
93
+ end
94
+
95
+ private
96
+
97
+ def matches_content_type?(pattern, content_type)
98
+ return true if pattern == content_type
99
+
100
+ # Handle wildcards like "text/*" or "image/*"
101
+ if pattern.end_with?("/*")
102
+ prefix = pattern[0..-3]
103
+ return content_type.start_with?(prefix)
104
+ end
105
+
106
+ false
107
+ end
108
+ end
109
+
110
+ # Response for listing agents
111
+ class AgentListResponse < Base
112
+ attribute :agents, default: -> { [] }
113
+ attribute :total
114
+ attribute :limit
115
+ attribute :offset
116
+
117
+ def self.from_hash(hash)
118
+ return nil if hash.nil?
119
+
120
+ instance = super
121
+
122
+ agents_data = hash["agents"] || hash[:agents] || []
123
+ instance.agents = agents_data.map { |a| AgentManifest.from_hash(a) }
124
+
125
+ instance
126
+ end
127
+ end
128
+ end
129
+ end