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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleAcp
4
+ module Server
5
+ # Wrapper for registered agent handlers.
6
+ #
7
+ # Encapsulates an agent's manifest (metadata) and handler (execution logic),
8
+ # normalizing different handler return types into a consistent format.
9
+ class Agent
10
+ # @return [Models::AgentManifest] the agent's metadata
11
+ attr_reader :manifest
12
+
13
+ # @return [#call] the handler that processes requests
14
+ attr_reader :handler
15
+
16
+ # Create a new agent.
17
+ #
18
+ # @param manifest [Models::AgentManifest] agent metadata
19
+ # @param handler [#call] callable that accepts a Context and returns messages
20
+ def initialize(manifest:, handler:)
21
+ @manifest = manifest
22
+ @handler = handler
23
+ end
24
+
25
+ # @return [String] the agent's name
26
+ def name
27
+ @manifest.name
28
+ end
29
+
30
+ # @return [String, nil] the agent's description
31
+ def description
32
+ @manifest.description
33
+ end
34
+
35
+ # Execute the agent handler with the given context.
36
+ #
37
+ # Normalizes various return types (Message, String, Array, Enumerator)
38
+ # into a consistent enumerable of {RunYield} or {RunYieldAwait} objects.
39
+ #
40
+ # @param context [Context] the execution context
41
+ # @return [Enumerable<RunYield, RunYieldAwait>] yielded results
42
+ def call(context)
43
+ result = @handler.call(context)
44
+
45
+ # Handle different return types
46
+ case result
47
+ when Enumerator, Enumerator::Lazy
48
+ # Generator-style handler that yields messages
49
+ result
50
+ when RunYield, RunYieldAwait
51
+ # Already wrapped
52
+ [result]
53
+ when Models::Message
54
+ # Single message return
55
+ [RunYield.new(result)]
56
+ when Array
57
+ # Array of messages or yields
58
+ result.map { |m| wrap_result_item(m) }
59
+ when String
60
+ # Simple string response
61
+ [RunYield.new(Models::Message.agent(result))]
62
+ else
63
+ # Try to convert to enumerator
64
+ result.respond_to?(:each) ? result.each : [wrap_result_item(result)]
65
+ end
66
+ end
67
+
68
+ # Check if the agent is valid for registration.
69
+ #
70
+ # @return [Boolean] true if manifest is valid and handler is callable
71
+ def valid?
72
+ @manifest.valid? && @handler.respond_to?(:call)
73
+ end
74
+
75
+ private
76
+
77
+ def wrap_result_item(item)
78
+ case item
79
+ when RunYield, RunYieldAwait
80
+ item
81
+ when Models::Message
82
+ RunYield.new(item)
83
+ when String
84
+ RunYield.new(Models::Message.agent(item))
85
+ else
86
+ item
87
+ end
88
+ end
89
+ end
90
+
91
+ # DSL module for defining agents with block syntax.
92
+ module AgentDSL
93
+ # Define an agent from a block.
94
+ #
95
+ # @param name [String] agent name (must follow RFC 1123 DNS label format)
96
+ # @param description [String, nil] human-readable description
97
+ # @param options [Hash] additional manifest options
98
+ # @option options [Array<String>] :input_content_types accepted MIME types
99
+ # @option options [Array<String>] :output_content_types produced MIME types
100
+ # @option options [Hash] :metadata agent metadata
101
+ # @yield [Context] block that handles agent requests
102
+ # @return [Agent] the created agent
103
+ def self.define(name:, description: nil, **options, &block)
104
+ manifest = Models::AgentManifest.new(
105
+ name: name,
106
+ description: description,
107
+ input_content_types: options[:input_content_types] || ["text/plain"],
108
+ output_content_types: options[:output_content_types] || ["text/plain"],
109
+ metadata: options[:metadata]
110
+ )
111
+
112
+ Agent.new(manifest: manifest, handler: block)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roda"
4
+ require "json"
5
+
6
+ module SimpleAcp
7
+ module Server
8
+ # Roda application implementing ACP HTTP endpoints.
9
+ #
10
+ # Provides REST endpoints for agent discovery, run management,
11
+ # and session handling. Supports sync, async, and streaming modes.
12
+ #
13
+ # == Endpoints
14
+ #
15
+ # - GET /ping - Health check
16
+ # - GET /agents - List agents
17
+ # - GET /agents/:name - Get agent manifest
18
+ # - POST /runs - Create a run
19
+ # - GET /runs/:id - Get run status
20
+ # - POST /runs/:id - Resume awaited run
21
+ # - POST /runs/:id/cancel - Cancel run
22
+ # - GET /runs/:id/events - Get run events
23
+ # - GET /session/:id - Get session info
24
+ class App < Roda
25
+ plugin :json
26
+ plugin :json_parser
27
+ plugin :all_verbs
28
+ plugin :halt
29
+
30
+ # Configure the Roda app with a server instance.
31
+ #
32
+ # @param server [Server::Base] the server to handle requests
33
+ # @return [void]
34
+ def self.configure(server)
35
+ @server = server
36
+ end
37
+
38
+ # Get the configured server instance.
39
+ #
40
+ # @return [Server::Base] the server
41
+ def self.server
42
+ @server
43
+ end
44
+
45
+ route do |r|
46
+ response["Content-Type"] = "application/json"
47
+
48
+ # Health check
49
+ r.get "ping" do
50
+ { status: "ok" }
51
+ end
52
+
53
+ # List agents
54
+ r.get "agents" do
55
+ limit = (r.params["limit"] || 10).to_i.clamp(1, 1000)
56
+ offset = (r.params["offset"] || 0).to_i
57
+
58
+ agents = self.class.server.agents.values
59
+ total = agents.length
60
+ agents = agents.drop(offset).take(limit)
61
+
62
+ {
63
+ agents: agents.map { |a| a.manifest.to_h },
64
+ total: total,
65
+ limit: limit,
66
+ offset: offset
67
+ }
68
+ end
69
+
70
+ # Get specific agent
71
+ r.get "agents", String do |name|
72
+ agent = self.class.server.agents[name]
73
+ unless agent
74
+ response.status = 404
75
+ r.halt({ error: Models::Error.not_found("Agent '#{name}' not found").to_h })
76
+ end
77
+ agent.manifest.to_h
78
+ end
79
+
80
+ # Runs endpoints
81
+ r.on "runs" do
82
+ # Create a new run (only matches POST /runs with no path segment)
83
+ r.is do
84
+ r.post do
85
+ body = r.params
86
+ request = Models::RunCreateRequest.from_hash(body)
87
+
88
+ unless request&.valid?
89
+ response.status = 400
90
+ r.halt({ error: Models::Error.invalid_input("Invalid run request").to_h })
91
+ end
92
+
93
+ mode = request.mode || Models::Types::RunMode::SYNC
94
+
95
+ begin
96
+ case mode
97
+ when Models::Types::RunMode::SYNC
98
+ run = self.class.server.run_sync(
99
+ agent_name: request.agent_name,
100
+ input: request.input,
101
+ session_id: request.session_id,
102
+ session: request.session
103
+ )
104
+ run.to_h
105
+ when Models::Types::RunMode::ASYNC
106
+ run = self.class.server.run_async(
107
+ agent_name: request.agent_name,
108
+ input: request.input,
109
+ session_id: request.session_id,
110
+ session: request.session
111
+ )
112
+ run.to_h
113
+ when Models::Types::RunMode::STREAM
114
+ headers = {
115
+ "Content-Type" => "text/event-stream",
116
+ "Cache-Control" => "no-cache",
117
+ "Connection" => "keep-alive",
118
+ "X-Accel-Buffering" => "no"
119
+ }
120
+
121
+ server_instance = self.class.server
122
+ req = request
123
+
124
+ body = proc do |stream|
125
+ begin
126
+ server_instance.run_stream(
127
+ agent_name: req.agent_name,
128
+ input: req.input,
129
+ session_id: req.session_id,
130
+ session: req.session
131
+ ) do |event|
132
+ stream.write(event.sse_format)
133
+ end
134
+ rescue => error
135
+ SimpleAcp.logger&.error("Stream error: #{error.message}")
136
+ ensure
137
+ stream.close rescue nil
138
+ end
139
+ end
140
+
141
+ r.halt [200, headers, body]
142
+ else
143
+ response.status = 400
144
+ r.halt({ error: Models::Error.invalid_input("Invalid mode: #{mode}").to_h })
145
+ end
146
+ rescue SimpleAcp::NotFoundError => e
147
+ response.status = 404
148
+ r.halt({ error: Models::Error.not_found(e.message).to_h })
149
+ rescue StandardError => e
150
+ response.status = 500
151
+ r.halt({ error: Models::Error.server_error(e.message).to_h })
152
+ end
153
+ end
154
+ end
155
+
156
+ r.on String do |run_id|
157
+ # Cancel a run (must come before r.is to match /cancel path)
158
+ r.post "cancel" do
159
+ begin
160
+ run = self.class.server.cancel_run(run_id)
161
+ run.to_h
162
+ rescue SimpleAcp::NotFoundError => e
163
+ response.status = 404
164
+ r.halt({ error: Models::Error.not_found(e.message).to_h })
165
+ end
166
+ end
167
+
168
+ # Get run events (must come before r.is to match /events path)
169
+ r.get "events" do
170
+ limit = (r.params["limit"] || 100).to_i.clamp(1, 1000)
171
+ offset = (r.params["offset"] || 0).to_i
172
+
173
+ events = self.class.server.storage.get_events(run_id, limit: limit, offset: offset)
174
+ { events: events.map(&:to_h) }
175
+ end
176
+
177
+ # Get run status or resume run (matches exactly /runs/:id)
178
+ r.is do
179
+ r.get do
180
+ run = self.class.server.storage.get_run(run_id)
181
+ unless run
182
+ response.status = 404
183
+ r.halt({ error: Models::Error.not_found("Run '#{run_id}' not found").to_h })
184
+ end
185
+ run.to_h
186
+ end
187
+
188
+ # Resume a paused run
189
+ r.post do
190
+ body = r.params
191
+ request = Models::RunResumeRequest.from_hash(body.merge("run_id" => run_id))
192
+
193
+ unless request&.valid?
194
+ response.status = 400
195
+ r.halt({ error: Models::Error.invalid_input("Invalid resume request").to_h })
196
+ end
197
+
198
+ mode = request.mode
199
+
200
+ begin
201
+ case mode
202
+ when Models::Types::RunMode::SYNC
203
+ run = self.class.server.resume_sync(
204
+ run_id: run_id,
205
+ await_resume: request.await_resume
206
+ )
207
+ run.to_h
208
+ when Models::Types::RunMode::STREAM
209
+ headers = {
210
+ "Content-Type" => "text/event-stream",
211
+ "Cache-Control" => "no-cache",
212
+ "Connection" => "keep-alive",
213
+ "X-Accel-Buffering" => "no"
214
+ }
215
+
216
+ server_instance = self.class.server
217
+ req = request
218
+ rid = run_id
219
+
220
+ body = proc do |stream|
221
+ begin
222
+ server_instance.resume_stream(
223
+ run_id: rid,
224
+ await_resume: req.await_resume
225
+ ) do |event|
226
+ stream.write(event.sse_format)
227
+ end
228
+ rescue => error
229
+ SimpleAcp.logger&.error("Resume stream error: #{error.message}")
230
+ ensure
231
+ stream.close rescue nil
232
+ end
233
+ end
234
+
235
+ r.halt [200, headers, body]
236
+ else
237
+ response.status = 400
238
+ r.halt({ error: Models::Error.invalid_input("Invalid mode: #{mode}").to_h })
239
+ end
240
+ rescue SimpleAcp::NotFoundError => e
241
+ response.status = 404
242
+ r.halt({ error: Models::Error.not_found(e.message).to_h })
243
+ rescue StandardError => e
244
+ response.status = 500
245
+ r.halt({ error: Models::Error.server_error(e.message).to_h })
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
251
+
252
+ # Session endpoint
253
+ r.get "session", String do |session_id|
254
+ session = self.class.server.storage.get_session(session_id)
255
+ unless session
256
+ response.status = 404
257
+ r.halt({ error: Models::Error.not_found("Session '#{session_id}' not found").to_h })
258
+ end
259
+ Models::SessionResponse.from_session(session).to_h
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end