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,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
|