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.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/CHANGELOG.md +5 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +385 -0
- data/Rakefile +13 -0
- data/docs/api/client-base.md +383 -0
- data/docs/api/index.md +159 -0
- data/docs/api/models.md +286 -0
- data/docs/api/server-base.md +379 -0
- data/docs/api/storage.md +347 -0
- data/docs/assets/images/simple_acp.jpg +0 -0
- data/docs/client/index.md +279 -0
- data/docs/client/sessions.md +324 -0
- data/docs/client/streaming.md +345 -0
- data/docs/client/sync-async.md +308 -0
- data/docs/core-concepts/agents.md +253 -0
- data/docs/core-concepts/events.md +337 -0
- data/docs/core-concepts/index.md +147 -0
- data/docs/core-concepts/messages.md +211 -0
- data/docs/core-concepts/runs.md +278 -0
- data/docs/core-concepts/sessions.md +281 -0
- data/docs/examples.md +659 -0
- data/docs/getting-started/configuration.md +166 -0
- data/docs/getting-started/index.md +62 -0
- data/docs/getting-started/installation.md +95 -0
- data/docs/getting-started/quick-start.md +189 -0
- data/docs/index.md +119 -0
- data/docs/server/creating-agents.md +360 -0
- data/docs/server/http-endpoints.md +411 -0
- data/docs/server/index.md +218 -0
- data/docs/server/multi-turn.md +329 -0
- data/docs/server/streaming.md +315 -0
- data/docs/storage/custom.md +414 -0
- data/docs/storage/index.md +176 -0
- data/docs/storage/memory.md +198 -0
- data/docs/storage/postgresql.md +350 -0
- data/docs/storage/redis.md +287 -0
- data/examples/01_basic/client.rb +88 -0
- data/examples/01_basic/server.rb +100 -0
- data/examples/02_async_execution/client.rb +107 -0
- data/examples/02_async_execution/server.rb +56 -0
- data/examples/03_run_management/client.rb +115 -0
- data/examples/03_run_management/server.rb +84 -0
- data/examples/04_rich_messages/client.rb +160 -0
- data/examples/04_rich_messages/server.rb +180 -0
- data/examples/05_await_resume/client.rb +164 -0
- data/examples/05_await_resume/server.rb +114 -0
- data/examples/06_agent_metadata/client.rb +188 -0
- data/examples/06_agent_metadata/server.rb +192 -0
- data/examples/README.md +252 -0
- data/examples/run_demo.sh +137 -0
- data/lib/simple_acp/client/base.rb +448 -0
- data/lib/simple_acp/client/sse.rb +141 -0
- data/lib/simple_acp/models/agent_manifest.rb +129 -0
- data/lib/simple_acp/models/await.rb +123 -0
- data/lib/simple_acp/models/base.rb +147 -0
- data/lib/simple_acp/models/errors.rb +102 -0
- data/lib/simple_acp/models/events.rb +256 -0
- data/lib/simple_acp/models/message.rb +235 -0
- data/lib/simple_acp/models/message_part.rb +225 -0
- data/lib/simple_acp/models/metadata.rb +161 -0
- data/lib/simple_acp/models/run.rb +298 -0
- data/lib/simple_acp/models/session.rb +137 -0
- data/lib/simple_acp/models/types.rb +210 -0
- data/lib/simple_acp/server/agent.rb +116 -0
- data/lib/simple_acp/server/app.rb +264 -0
- data/lib/simple_acp/server/base.rb +510 -0
- data/lib/simple_acp/server/context.rb +210 -0
- data/lib/simple_acp/server/falcon_runner.rb +61 -0
- data/lib/simple_acp/storage/base.rb +129 -0
- data/lib/simple_acp/storage/memory.rb +108 -0
- data/lib/simple_acp/storage/postgresql.rb +233 -0
- data/lib/simple_acp/storage/redis.rb +178 -0
- data/lib/simple_acp/version.rb +5 -0
- data/lib/simple_acp.rb +91 -0
- data/mkdocs.yml +152 -0
- data/sig/simple_acp.rbs +4 -0
- 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
|