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,510 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleAcp
4
+ module Server
5
+ # Main ACP Server class for hosting agents and handling requests.
6
+ #
7
+ # The server manages agent registration, run execution (sync, async, stream),
8
+ # session state, and exposes an HTTP API via Roda/Falcon.
9
+ #
10
+ # @example Creating and running a server
11
+ # server = SimpleAcp::Server::Base.new
12
+ # server.agent("echo", description: "Echoes input") do |context|
13
+ # SimpleAcp::Models::Message.agent(context.input.first.text_content)
14
+ # end
15
+ # server.run(port: 8000)
16
+ #
17
+ # @example Using custom storage
18
+ # storage = SimpleAcp::Storage::Redis.new(url: "redis://localhost:6379")
19
+ # server = SimpleAcp::Server::Base.new(storage: storage)
20
+ class Base
21
+ # @return [Hash<String, Agent>] registered agents indexed by name
22
+ attr_reader :agents
23
+
24
+ # @return [Storage::Base] storage backend for runs, sessions, and events
25
+ attr_reader :storage
26
+
27
+ # @return [Hash] additional configuration options
28
+ attr_reader :options
29
+
30
+ # Initialize a new ACP server.
31
+ #
32
+ # @param storage [Storage::Base, nil] storage backend (defaults to Memory)
33
+ # @param options [Hash] additional configuration options
34
+ def initialize(storage: nil, **options)
35
+ @agents = {}
36
+ @storage = storage || SimpleAcp::Storage::Memory.new
37
+ @options = options
38
+ @running_contexts = Concurrent::Map.new
39
+ end
40
+
41
+ # Register an agent using block syntax or decorator-style.
42
+ #
43
+ # @param name [String, nil] agent name (must follow RFC 1123 DNS label format)
44
+ # @param description [String, nil] human-readable description
45
+ # @param options [Hash] additional options
46
+ # @option options [Array<String>] :input_content_types accepted MIME types
47
+ # @option options [Array<String>] :output_content_types produced MIME types
48
+ # @option options [Hash] :metadata agent metadata
49
+ # @yield [Context] block that handles agent requests
50
+ # @return [Agent, Proc] the registered agent or a decorator lambda
51
+ #
52
+ # @example Block syntax
53
+ # server.agent("greeter", description: "Greets users") do |context|
54
+ # name = context.input.first&.text_content || "World"
55
+ # SimpleAcp::Models::Message.agent("Hello, #{name}!")
56
+ # end
57
+ #
58
+ # @example Streaming agent
59
+ # server.agent("counter") do |context|
60
+ # Enumerator.new do |yielder|
61
+ # 3.times { |i| yielder << SimpleAcp::Models::Message.agent("Count: #{i}") }
62
+ # end
63
+ # end
64
+ def agent(name = nil, description: nil, **options, &block)
65
+ if block_given?
66
+ # Direct registration with block
67
+ agent_obj = AgentDSL.define(
68
+ name: name,
69
+ description: description,
70
+ **options,
71
+ &block
72
+ )
73
+ register(agent_obj)
74
+ agent_obj
75
+ else
76
+ # Return a lambda for decorator-style usage
77
+ ->(handler) do
78
+ agent_name = name || handler_name(handler)
79
+ agent_obj = Agent.new(
80
+ manifest: Models::AgentManifest.new(
81
+ name: agent_name,
82
+ description: description,
83
+ **options
84
+ ),
85
+ handler: handler
86
+ )
87
+ register(agent_obj)
88
+ handler
89
+ end
90
+ end
91
+ end
92
+
93
+ # Register an agent instance directly.
94
+ #
95
+ # @param agent [Agent] the agent to register
96
+ # @return [Agent] the registered agent
97
+ # @raise [ValidationError] if the agent is invalid
98
+ # @raise [ConfigurationError] if an agent with the same name exists
99
+ def register(agent)
100
+ raise SimpleAcp::ValidationError, "Invalid agent" unless agent.valid?
101
+ raise SimpleAcp::ConfigurationError, "Agent '#{agent.name}' already registered" if @agents.key?(agent.name)
102
+
103
+ @agents[agent.name] = agent
104
+ agent
105
+ end
106
+
107
+ # Unregister an agent by name.
108
+ #
109
+ # @param name [String] the agent name to remove
110
+ # @return [Agent, nil] the removed agent or nil if not found
111
+ def unregister(name)
112
+ @agents.delete(name)
113
+ end
114
+
115
+ # Run an agent synchronously, blocking until completion.
116
+ #
117
+ # @param agent_name [String] name of the agent to run
118
+ # @param input [Array<Models::Message>, Models::Message, String] input messages
119
+ # @param session_id [String, nil] optional session ID for stateful interactions
120
+ # @param session [Models::Session, Hash, nil] optional session data
121
+ # @return [Models::Run] the completed run with output
122
+ # @raise [NotFoundError] if the agent is not found
123
+ def run_sync(agent_name:, input:, session_id: nil, session: nil)
124
+ run, context = prepare_run(agent_name, input, session_id, session)
125
+
126
+ begin
127
+ run.start!
128
+ @storage.save_run(run)
129
+
130
+ output_messages = []
131
+ execute_agent(context) do |yielded|
132
+ case yielded
133
+ when RunYield
134
+ output_messages << yielded.message
135
+ when RunYieldAwait
136
+ # Agent is awaiting - save state and return
137
+ return run
138
+ end
139
+ end
140
+
141
+ run.complete!(output_messages)
142
+ update_session_history(context.session, input, output_messages)
143
+ rescue StandardError => e
144
+ run.fail!(e.message)
145
+ end
146
+
147
+ @storage.save_run(run)
148
+ run
149
+ end
150
+
151
+ # Run an agent asynchronously, returning immediately with a run ID.
152
+ #
153
+ # The agent executes in a background thread. Use {#cancel_run} to stop
154
+ # or poll the storage to check status.
155
+ #
156
+ # @param agent_name [String] name of the agent to run
157
+ # @param input [Array<Models::Message>, Models::Message, String] input messages
158
+ # @param session_id [String, nil] optional session ID
159
+ # @param session [Models::Session, Hash, nil] optional session data
160
+ # @return [Models::Run] the run (status will be :created or :in_progress)
161
+ # @raise [NotFoundError] if the agent is not found
162
+ def run_async(agent_name:, input:, session_id: nil, session: nil)
163
+ run, context = prepare_run(agent_name, input, session_id, session)
164
+
165
+ Thread.new do
166
+ begin
167
+ run.start!
168
+ @storage.save_run(run)
169
+
170
+ output_messages = []
171
+ awaiting = false
172
+
173
+ execute_agent(context) do |yielded|
174
+ case yielded
175
+ when RunYield
176
+ output_messages << yielded.message
177
+ when RunYieldAwait
178
+ awaiting = true
179
+ break
180
+ end
181
+ end
182
+
183
+ # Check if cancelled or awaiting before completing
184
+ if context.cancelled?
185
+ run.cancelled!
186
+ elsif !awaiting
187
+ run.complete!(output_messages)
188
+ update_session_history(context.session, input, output_messages)
189
+ end
190
+ # If awaiting, run is already in awaiting state from await_message
191
+ rescue StandardError => e
192
+ run.fail!(e.message)
193
+ ensure
194
+ @storage.save_run(run)
195
+ @running_contexts.delete(run.run_id)
196
+ end
197
+ end
198
+
199
+ run
200
+ end
201
+
202
+ # Run an agent with streaming output via Server-Sent Events.
203
+ #
204
+ # Yields events as the agent executes, enabling real-time response streaming.
205
+ #
206
+ # @param agent_name [String] name of the agent to run
207
+ # @param input [Array<Models::Message>, Models::Message, String] input messages
208
+ # @param session_id [String, nil] optional session ID
209
+ # @param session [Models::Session, Hash, nil] optional session data
210
+ # @yield [Models::Event] events as they occur during execution
211
+ # @yieldparam event [Models::RunCreatedEvent, Models::MessagePartEvent, Models::RunCompletedEvent, etc.]
212
+ # @return [void]
213
+ # @raise [NotFoundError] if the agent is not found
214
+ #
215
+ # @example
216
+ # server.run_stream(agent_name: "echo", input: "Hello") do |event|
217
+ # case event
218
+ # when Models::MessagePartEvent
219
+ # print event.part.content
220
+ # when Models::RunCompletedEvent
221
+ # puts "\nDone!"
222
+ # end
223
+ # end
224
+ def run_stream(agent_name:, input:, session_id: nil, session: nil)
225
+ run, context = prepare_run(agent_name, input, session_id, session)
226
+
227
+ begin
228
+ yield Models::RunCreatedEvent.new(run: run)
229
+ @storage.add_event(run.run_id, Models::RunCreatedEvent.new(run: run))
230
+
231
+ run.start!
232
+ @storage.save_run(run)
233
+ yield Models::RunInProgressEvent.new(run_id: run.run_id)
234
+ @storage.add_event(run.run_id, Models::RunInProgressEvent.new(run_id: run.run_id))
235
+
236
+ output_messages = []
237
+
238
+ execute_agent(context) do |yielded|
239
+ case yielded
240
+ when RunYield
241
+ message = yielded.message
242
+ output_messages << message
243
+
244
+ yield Models::MessageCreatedEvent.new(message: message)
245
+ @storage.add_event(run.run_id, Models::MessageCreatedEvent.new(message: message))
246
+
247
+ message.parts.each do |part|
248
+ yield Models::MessagePartEvent.new(part: part)
249
+ @storage.add_event(run.run_id, Models::MessagePartEvent.new(part: part))
250
+ end
251
+
252
+ yield Models::MessageCompletedEvent.new(message: message)
253
+ @storage.add_event(run.run_id, Models::MessageCompletedEvent.new(message: message))
254
+ when RunYieldAwait
255
+ yield Models::RunAwaitingEvent.new(run_id: run.run_id, await_request: yielded.request)
256
+ @storage.add_event(run.run_id, Models::RunAwaitingEvent.new(run_id: run.run_id, await_request: yielded.request))
257
+ return
258
+ end
259
+ end
260
+
261
+ run.complete!(output_messages)
262
+ update_session_history(context.session, input, output_messages)
263
+
264
+ yield Models::RunCompletedEvent.new(run: run)
265
+ @storage.add_event(run.run_id, Models::RunCompletedEvent.new(run: run))
266
+ rescue StandardError => e
267
+ run.fail!(e.message)
268
+ yield Models::RunFailedEvent.new(run_id: run.run_id, error: run.error)
269
+ @storage.add_event(run.run_id, Models::RunFailedEvent.new(run_id: run.run_id, error: run.error))
270
+ ensure
271
+ @storage.save_run(run)
272
+ @running_contexts.delete(run.run_id)
273
+ end
274
+ end
275
+
276
+ # Resume an awaited run synchronously.
277
+ #
278
+ # When an agent yields a {RunYieldAwait}, the run enters an "awaiting" state.
279
+ # Use this method to provide the requested input and continue execution.
280
+ #
281
+ # @param run_id [String] the run ID to resume
282
+ # @param await_resume [Models::AwaitResume] the resume payload with client response
283
+ # @return [Models::Run] the completed run
284
+ # @raise [NotFoundError] if the run is not found
285
+ # @raise [ValidationError] if the run is not in awaiting state
286
+ def resume_sync(run_id:, await_resume:)
287
+ run, context = prepare_resume(run_id, await_resume)
288
+
289
+ begin
290
+ run.start!
291
+ @storage.save_run(run)
292
+
293
+ output_messages = run.output.dup
294
+
295
+ execute_agent(context) do |yielded|
296
+ case yielded
297
+ when RunYield
298
+ output_messages << yielded.message
299
+ when RunYieldAwait
300
+ return run
301
+ end
302
+ end
303
+
304
+ run.complete!(output_messages)
305
+ update_session_history(context.session, [await_resume.message].compact, output_messages)
306
+ rescue StandardError => e
307
+ run.fail!(e.message)
308
+ end
309
+
310
+ @storage.save_run(run)
311
+ run
312
+ end
313
+
314
+ # Resume an awaited run with streaming output.
315
+ #
316
+ # @param run_id [String] the run ID to resume
317
+ # @param await_resume [Models::AwaitResume] the resume payload with client response
318
+ # @yield [Models::Event] events as they occur during execution
319
+ # @return [void]
320
+ # @raise [NotFoundError] if the run is not found
321
+ # @raise [ValidationError] if the run is not in awaiting state
322
+ def resume_stream(run_id:, await_resume:)
323
+ run, context = prepare_resume(run_id, await_resume)
324
+
325
+ begin
326
+ run.start!
327
+ @storage.save_run(run)
328
+ yield Models::RunInProgressEvent.new(run_id: run.run_id)
329
+ @storage.add_event(run.run_id, Models::RunInProgressEvent.new(run_id: run.run_id))
330
+
331
+ output_messages = run.output.dup
332
+
333
+ execute_agent(context) do |yielded|
334
+ case yielded
335
+ when RunYield
336
+ message = yielded.message
337
+ output_messages << message
338
+
339
+ yield Models::MessageCreatedEvent.new(message: message)
340
+ @storage.add_event(run.run_id, Models::MessageCreatedEvent.new(message: message))
341
+
342
+ yield Models::MessageCompletedEvent.new(message: message)
343
+ @storage.add_event(run.run_id, Models::MessageCompletedEvent.new(message: message))
344
+ when RunYieldAwait
345
+ yield Models::RunAwaitingEvent.new(run_id: run.run_id, await_request: yielded.request)
346
+ @storage.add_event(run.run_id, Models::RunAwaitingEvent.new(run_id: run.run_id, await_request: yielded.request))
347
+ return
348
+ end
349
+ end
350
+
351
+ run.complete!(output_messages)
352
+ yield Models::RunCompletedEvent.new(run: run)
353
+ @storage.add_event(run.run_id, Models::RunCompletedEvent.new(run: run))
354
+ rescue StandardError => e
355
+ run.fail!(e.message)
356
+ yield Models::RunFailedEvent.new(run_id: run.run_id, error: run.error)
357
+ @storage.add_event(run.run_id, Models::RunFailedEvent.new(run_id: run.run_id, error: run.error))
358
+ ensure
359
+ @storage.save_run(run)
360
+ @running_contexts.delete(run.run_id)
361
+ end
362
+ end
363
+
364
+ # Cancel a running agent execution.
365
+ #
366
+ # @param run_id [String] the run ID to cancel
367
+ # @return [Models::Run] the cancelled run
368
+ # @raise [NotFoundError] if the run is not found
369
+ def cancel_run(run_id)
370
+ run = @storage.get_run(run_id)
371
+ raise SimpleAcp::NotFoundError, "Run '#{run_id}' not found" unless run
372
+
373
+ context = @running_contexts[run_id]
374
+ context&.cancel!
375
+
376
+ run.cancelled!
377
+ @storage.save_run(run)
378
+ run
379
+ end
380
+
381
+ # Create a Rack-compatible application.
382
+ #
383
+ # @return [Roda] the Rack application
384
+ def to_app
385
+ # Create a subclass to avoid freezing the base App class
386
+ app_class = Class.new(App)
387
+ app_class.configure(self)
388
+ app_class.freeze.app
389
+ end
390
+
391
+ # Start the HTTP server using Falcon.
392
+ #
393
+ # Falcon provides fiber-based concurrency for efficient handling
394
+ # of SSE streams and long-lived connections.
395
+ #
396
+ # @param port [Integer] port to listen on (default: 8000)
397
+ # @param host [String] host to bind to (default: "0.0.0.0")
398
+ # @param options [Hash] additional Falcon configuration options
399
+ # @return [void]
400
+ def run(port: 8000, host: "0.0.0.0", **options)
401
+ require_relative "falcon_runner"
402
+
403
+ app = to_app
404
+
405
+ puts "Registered agents: #{@agents.keys.join(', ')}"
406
+
407
+ FalconRunner.run(app, port: port, host: host, **options)
408
+ end
409
+
410
+ private
411
+
412
+ def prepare_run(agent_name, input, session_id, session_data)
413
+ agent = @agents[agent_name]
414
+ raise SimpleAcp::NotFoundError, "Agent '#{agent_name}' not found" unless agent
415
+
416
+ session = resolve_session(session_id, session_data)
417
+
418
+ run = Models::Run.new(
419
+ agent_name: agent_name,
420
+ session_id: session&.id
421
+ )
422
+ @storage.save_run(run)
423
+
424
+ context = Context.new(
425
+ run: run,
426
+ session: session,
427
+ input: input,
428
+ server: self
429
+ )
430
+ @running_contexts[run.run_id] = context
431
+
432
+ [run, context]
433
+ end
434
+
435
+ def prepare_resume(run_id, await_resume)
436
+ run = @storage.get_run(run_id)
437
+ raise SimpleAcp::NotFoundError, "Run '#{run_id}' not found" unless run
438
+ raise SimpleAcp::ValidationError, "Run is not awaiting" unless run.awaiting?
439
+
440
+ session = run.session_id ? @storage.get_session(run.session_id) : nil
441
+
442
+ context = ResumeContext.new(
443
+ run: run,
444
+ session: session,
445
+ input: [],
446
+ server: self,
447
+ await_resume: await_resume
448
+ )
449
+ @running_contexts[run.run_id] = context
450
+
451
+ [run, context]
452
+ end
453
+
454
+ def resolve_session(session_id, session_data)
455
+ if session_data
456
+ session = Models::Session.from_hash(session_data.is_a?(Hash) ? session_data : session_data.to_h)
457
+ @storage.save_session(session)
458
+ session
459
+ elsif session_id
460
+ @storage.get_session(session_id) || begin
461
+ session = Models::Session.new(id: session_id)
462
+ @storage.save_session(session)
463
+ session
464
+ end
465
+ end
466
+ end
467
+
468
+ def execute_agent(context)
469
+ agent = @agents[context.agent_name]
470
+ return unless agent
471
+
472
+ result = agent.call(context)
473
+
474
+ if result.respond_to?(:each)
475
+ result.each do |item|
476
+ break if context.cancelled?
477
+
478
+ case item
479
+ when RunYield, RunYieldAwait
480
+ yield item
481
+ when Models::Message
482
+ yield RunYield.new(item)
483
+ when String
484
+ yield RunYield.new(Models::Message.agent(item))
485
+ end
486
+ end
487
+ end
488
+ end
489
+
490
+ def update_session_history(session, input, output)
491
+ return unless session
492
+
493
+ Array(input).each { |msg| session.add_to_history(msg) }
494
+ Array(output).each { |msg| session.add_to_history(msg) }
495
+ @storage.save_session(session)
496
+ end
497
+
498
+ def handler_name(handler)
499
+ case handler
500
+ when Method
501
+ handler.name.to_s
502
+ when Proc
503
+ "anonymous_agent"
504
+ else
505
+ handler.class.name.downcase.gsub("::", "_")
506
+ end
507
+ end
508
+ end
509
+ end
510
+ end