|
1 | | -# arcp |
| 1 | +<h3 align="center">ARCP Ruby SDK</h3> |
2 | 2 |
|
3 | | -Ruby SDK for ARCP v1. The wire protocol is specified in |
4 | | -[the ARCP spec](https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md). This |
5 | | -gem implements the full v1 envelope, session handshake, job lifecycle, |
6 | | -event stream, leases, and error model. |
| 3 | +<p align="center"><strong>Ruby SDK for the Agent Runtime Control Protocol (ARCP) — submit, observe, and control long-running agent jobs from Ruby.</strong></p> |
7 | 4 |
|
8 | | -## Install |
| 5 | +<p align="center"> |
| 6 | + <a href="https://rubygems.org/gems/arcp"><img alt="gem" src="https://img.shields.io/gem/v/arcp.svg"></a> |
| 7 | + <a href="https://github.com/nficano/arpc/actions/workflows/test.yml"><img alt="CI" src="https://github.com/nficano/arpc/actions/workflows/test.yml/badge.svg"></a> |
| 8 | + <a href="https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md"><img alt="ARCP" src="https://img.shields.io/badge/ARCP-v1.1%20draft-blue"></a> |
| 9 | + <a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache--2.0-lightgrey"></a> |
| 10 | +</p> |
| 11 | + |
| 12 | +<p align="center"> |
| 13 | + <a href="https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md">Specification</a> · |
| 14 | + <a href="#concepts">Concepts</a> · |
| 15 | + <a href="#installation">Install</a> · |
| 16 | + <a href="#quick-start">Quick start</a> · |
| 17 | + <a href="docs/">Guides</a> · |
| 18 | + <a href="docs/api/">API reference</a> |
| 19 | +</p> |
| 20 | + |
| 21 | +--- |
| 22 | + |
| 23 | +`arcp` is the Ruby reference implementation of [ARCP](https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md), the Agent Runtime Control Protocol. It covers both sides of the wire — `Arcp::Client` for submitting and observing jobs, `Arcp::Runtime::Runtime` for hosting agents — so either side can talk to any conformant peer in any language without hand-rolling the envelope, sequencing, or lease enforcement. |
| 24 | + |
| 25 | +ARCP itself is a transport-agnostic wire protocol for long-running AI agent jobs. It owns the parts of agent infrastructure that don't change between products — sessions, durable event streams, capability leases, budgets, resume — and stays out of the parts that do. ARCP wraps the agent function; it does not define how agents are built, how tools are exposed (that's MCP), or how telemetry is exported (that's OpenTelemetry). |
| 26 | + |
| 27 | +## Installation |
| 28 | + |
| 29 | +Requires Ruby 3.3 or later. The gem runs on the `socketry/async` reactor and pulls in `async-websocket` for the default networked transport and `sqlite3` for the resume log; no separate extras are needed. Add it to a `Gemfile`: |
9 | 30 |
|
10 | 31 | ```ruby |
11 | 32 | gem 'arcp', '~> 1.0' |
12 | 33 | ``` |
13 | 34 |
|
14 | | -Requires Ruby 3.3+. Runs on the `socketry/async` reactor; pairs with |
15 | | -`falcon` for hosting and `async-websocket` for the WebSocket transport. |
| 35 | +```sh |
| 36 | +bundle install |
| 37 | +``` |
| 38 | + |
| 39 | +## Quick start |
16 | 40 |
|
17 | | -## Quickstart |
| 41 | +Connect to a runtime, submit a job, stream its events to completion: |
18 | 42 |
|
19 | 43 | ```ruby |
20 | 44 | require 'async' |
21 | 45 | require 'arcp' |
22 | 46 |
|
| 47 | +ECHO = lambda do |ctx| |
| 48 | + ctx.log(level: 'info', message: "echoing #{ctx.input.inspect}") |
| 49 | + ctx.progress(current: 1, total: 1, units: 'message') |
| 50 | + ctx.finish(result: { 'echoed' => ctx.input }) |
| 51 | +end |
| 52 | + |
23 | 53 | Sync do |
24 | 54 | runtime = Arcp::Runtime::Runtime.new( |
25 | 55 | auth_verifier: Arcp::Auth::Bearer.from_token('demo', principal_id: 'alice'), |
26 | 56 | heartbeat_interval_sec: nil |
27 | 57 | ) |
28 | | - runtime.register_agent( |
29 | | - name: 'echo', versions: ['1.0.0'], default: '1.0.0', |
30 | | - handler: ->(ctx) { |
31 | | - ctx.progress(current: 1, total: 1, units: 'message') |
32 | | - ctx.finish(result: { 'echoed' => ctx.input }) |
33 | | - } |
34 | | - ) |
| 58 | + runtime.register_agent(name: 'echo', versions: ['1.0.0'], default: '1.0.0', handler: ECHO) |
35 | 59 |
|
36 | 60 | server_t, client_t = Arcp::Transport::MemoryTransport.pair |
37 | 61 | server = Async { runtime.accept(server_t) } |
38 | 62 |
|
39 | 63 | client = Arcp::Client.open( |
40 | 64 | transport: client_t, |
41 | | - auth: { 'scheme' => 'bearer', 'token' => 'demo' } |
| 65 | + auth: { 'scheme' => 'bearer', 'token' => 'demo' }, |
| 66 | + client_name: 'quickstart' |
42 | 67 | ) |
| 68 | + |
43 | 69 | handle = client.submit_job(agent: 'echo', input: { 'msg' => 'hi' }) |
44 | | - handle.subscribe(client: client).each { |ev| puts ev.kind } |
45 | | - puts handle.get_result(client: client).result.inspect |
| 70 | + handle.subscribe(client: client).each { |event| puts "#{event.kind}: #{event.body.to_h}" } |
| 71 | + result = handle.get_result(client: client) |
| 72 | + puts "final: #{result.final_status} #{result.result.inspect}" |
46 | 73 |
|
47 | 74 | client.close |
48 | 75 | server.stop |
49 | 76 | end |
50 | 77 | ``` |
51 | 78 |
|
52 | | -## What is ARCP |
| 79 | +This is the whole shape of the SDK: open a session, submit work, consume an ordered event stream, get a terminal result or error. Everything below is detail on those four moves. |
| 80 | + |
| 81 | +## Concepts |
| 82 | + |
| 83 | +ARCP organizes everything around four concerns — **identity**, **durability**, **authority**, and **observability** — expressed through five core objects: |
| 84 | + |
| 85 | +- **Session** — a connection between a client and a runtime. A session carries identity (a bearer token), negotiates a feature set in a `hello`/`welcome` handshake, and is *resumable*: if the transport drops, you reconnect with a resume token and the runtime replays buffered events. Jobs outlive the session that started them. See [§6](https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md). |
| 86 | +- **Job** — one unit of agent work submitted into a session. A job has an identity, an optional idempotency key, a resolved agent version, and a lifecycle that ends in exactly one terminal state: `success`, `error`, `cancelled`, or `timed_out`. See [§7](https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md). |
| 87 | +- **Event** — the ordered, session-scoped stream a job emits: logs, thoughts, tool calls and results, status, metrics, artifact references, progress, and streamed result chunks. Events carry strictly monotonic sequence numbers so the stream survives reconnects gap-free. See [§8](https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md). |
| 88 | +- **Lease** — the authority a job runs under, expressed as capability grants (`fs.read`, `fs.write`, `net.fetch`, `tool.call`, `agent.delegate`, `cost.budget`, `model.use`). The runtime enforces the lease at every operation boundary; a job can never act outside it. Leases may carry a budget and an expiry, and may be subset and handed to sub-agents via delegation. See [§9](https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md). |
| 89 | +- **Subscription** — read-only attachment to a job started elsewhere (e.g. a dashboard watching a job a CLI submitted). A subscriber observes the live event stream but cannot cancel or mutate the job. Distinct from *resume*, which continues the original session and carries cancel authority. See [§7.6](https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md). |
| 90 | + |
| 91 | +The SDK models each of these as first-class objects; the rest of this README shows how. |
53 | 92 |
|
54 | | -ARCP is a session-oriented protocol for invoking remote agents. A client |
55 | | -opens a session, submits jobs, and receives a stream of structured events |
56 | | -followed by a terminal result or error. The protocol covers capability |
57 | | -negotiation, heartbeats, ordered acks, cursored job listing, cross-session |
58 | | -observation, capability-bounded leases, and trace propagation. |
| 93 | +## Guides |
| 94 | + |
| 95 | +### Sessions and resume |
| 96 | + |
| 97 | +Open a session, negotiate features, and reconnect transparently after a transport drop using the resume token — jobs keep running server-side while you're gone. |
| 98 | + |
| 99 | +```ruby |
| 100 | +require 'async' |
| 101 | +require 'arcp' |
| 102 | + |
| 103 | +Sync do |
| 104 | + client = Arcp::Client.open( |
| 105 | + transport: transport, |
| 106 | + auth: { 'scheme' => 'bearer', 'token' => ENV.fetch('ARCP_TOKEN') }, |
| 107 | + client_name: 'resumable' |
| 108 | + ) |
| 109 | + |
| 110 | + session_id = client.session.id |
| 111 | + resume_token = client.session.resume_token |
| 112 | + last_seq = Hash.new(0) |
| 113 | + handle = client.submit_job(agent: 'long-runner') |
| 114 | + handle.subscribe(client: client).each do |event| |
| 115 | + last_seq[handle.job_id] = event.body.respond_to?(:seq) ? event.body.seq : last_seq[handle.job_id] |
| 116 | + end |
| 117 | + |
| 118 | + # ... transport drops ... |
| 119 | + |
| 120 | + resumed = Arcp::Client.open( |
| 121 | + transport: new_transport, |
| 122 | + auth: { 'scheme' => 'bearer', 'token' => ENV.fetch('ARCP_TOKEN') }, |
| 123 | + resume: { 'token' => resume_token, 'last_event_seq' => last_seq } |
| 124 | + ) |
| 125 | + # The runtime replays every event with event_seq > last_seq, then resumes live streaming. |
| 126 | +end |
| 127 | +``` |
59 | 128 |
|
60 | | -## Features |
| 129 | +### Submitting jobs |
61 | 130 |
|
62 | | -- Capability negotiation (§6.2) |
63 | | -- Heartbeat / ping-pong (§6.4) |
64 | | -- Application-level ack (§6.5) |
65 | | -- Cursored `list_jobs` (§6.6) |
66 | | -- Cross-session `job.subscribe` with history replay (§7.6) |
67 | | -- Agent versioning with `name@version` refs (§7.5) |
68 | | -- `result_chunk` streaming with result_id terminator (§8.4) |
69 | | -- `progress` events (§8.2) |
70 | | -- `lease_constraints.expires_at` (§9) |
71 | | -- `cost.budget` capability with `BigDecimal` arithmetic (§9.6) |
72 | | -- Resume token + last_event_seq replay (§6.3) |
73 | | -- Trace context propagation (§11) |
| 131 | +Submit a job with an agent (optionally version-pinned as `name@version`), an input, and an optional lease request, idempotency key, and runtime limit. |
74 | 132 |
|
75 | | -## Architecture |
| 133 | +```ruby |
| 134 | +handle = client.submit_job( |
| 135 | + agent: 'weekly-report@2.1.0', |
| 136 | + input: { 'week' => '2026-W19' }, |
| 137 | + lease_request: Arcp::Lease::LeaseRequest.new( |
| 138 | + capabilities: ['net.fetch'], |
| 139 | + expires_at: (Time.now.utc + 60).iso8601 |
| 140 | + ), |
| 141 | + lease_constraints: Arcp::Lease::LeaseConstraints.new( |
| 142 | + expires_at: (Time.now.utc + 300).iso8601, |
| 143 | + max_budget: nil |
| 144 | + ), |
| 145 | + idempotency_key: 'weekly-report-2026-W19', |
| 146 | + max_runtime_sec: 300 |
| 147 | +) |
76 | 148 |
|
| 149 | +puts "job_id = #{handle.job_id}" |
| 150 | +puts "resolved agent = #{handle.agent.inspect}" |
| 151 | +puts "effective lease = #{handle.lease&.to_h.inspect}" |
77 | 152 | ``` |
78 | | -Arcp::Client # session-oriented client |
79 | | -Arcp::Runtime::Runtime # server-side runtime; accepts transports |
80 | | -Arcp::Runtime::JobContext # passed to agent handlers |
81 | | -Arcp::Session::* # Hello, Welcome, CapabilitySet, Feature, AgentInventory, ... |
82 | | -Arcp::Job::* # Submit, Accepted, Event, Result, JobError, Handle, Summary |
83 | | -Arcp::Job::EventKind # 10 standard kinds |
84 | | -Arcp::Lease::* # Lease, LeaseRequest, LeaseConstraints, CostBudget, Subsetting |
85 | | -Arcp::Transport::* # MemoryTransport, WebSocketTransport, StdioTransport |
86 | | -Arcp::Auth::* # AuthScheme, Bearer, Principal |
87 | | -Arcp::Errors::* # 15 wire codes + 3 internal-only |
88 | | -Arcp::Trace # Fiber-local Context, span helpers |
| 153 | + |
| 154 | +### Consuming events |
| 155 | + |
| 156 | +Iterate the ordered event stream — `log`, `thought`, `tool_call`, `tool_result`, `status`, `metric`, `artifact_ref`, `progress`, `result_chunk` — and optionally acknowledge progress so the runtime can release buffered events early. |
| 157 | + |
| 158 | +```ruby |
| 159 | +last_seq = 0 |
| 160 | +handle.subscribe(client: client).each do |event| |
| 161 | + case event.kind |
| 162 | + when Arcp::Job::EventKind::LOG |
| 163 | + puts event.body.message |
| 164 | + when Arcp::Job::EventKind::TOOL_CALL |
| 165 | + puts "-> tool #{event.body.name}(#{event.body.arguments.inspect})" |
| 166 | + when Arcp::Job::EventKind::METRIC |
| 167 | + puts "metric #{event.body.name}=#{event.body.value}#{event.body.unit}" |
| 168 | + when Arcp::Job::EventKind::PROGRESS |
| 169 | + puts "progress #{event.body.current}/#{event.body.total} #{event.body.units}" |
| 170 | + end |
| 171 | + last_seq += 1 |
| 172 | + client.ack(last_seq) if (last_seq % 32).zero? # coalesced session.ack |
| 173 | +end |
89 | 174 | ``` |
90 | 175 |
|
91 | | -## Transports |
| 176 | +### Leases and budgets |
92 | 177 |
|
93 | | -- `Arcp::Transport::MemoryTransport.pair` — in-process queue pair. Tests, embedded clients. |
94 | | -- `Arcp::Transport::WebSocketTransport` — wraps an `Async::WebSocket::Connection`. Production transport. |
95 | | -- `Arcp::Transport::StdioTransport` — newline-delimited JSON over a pair of IOs. Co-process agents. |
| 178 | +Request capabilities, a budget, and an expiry; read budget-remaining metrics as they arrive; handle the runtime's enforcement decisions. |
96 | 179 |
|
97 | | -## Deployment |
| 180 | +```ruby |
| 181 | +handle = client.submit_job( |
| 182 | + agent: 'web-research', |
| 183 | + input: { 'iterations' => 8 }, |
| 184 | + lease_request: Arcp::Lease::LeaseRequest.new( |
| 185 | + capabilities: ['tool.call', 'cost.spend'], |
| 186 | + budget: Arcp::Lease::CostBudget.parse(['USD:1.00']), |
| 187 | + expires_at: (Time.now.utc + 600).iso8601 |
| 188 | + ) |
| 189 | +) |
98 | 190 |
|
99 | | -Run the runtime as a daemon under the `socketry/async` reactor. Host |
100 | | -WebSocket endpoints with `falcon`. The runtime is fiber-based and |
101 | | -multiplexes sessions on the reactor; do not deploy under |
102 | | -request-per-thread servers like Puma. |
| 191 | +puts "initial budget = #{handle.lease&.budget&.to_a.inspect}" |
103 | 192 |
|
104 | | -## Errors |
| 193 | +handle.subscribe(client: client).each do |event| |
| 194 | + next unless event.kind == Arcp::Job::EventKind::METRIC |
| 195 | + next unless event.body.name == 'cost.budget.remaining' |
105 | 196 |
|
106 | | -15 wire codes, each mapped to an `Arcp::Errors::*` subclass with a |
107 | | -`retryable?` default: `Cancelled`, `InvalidRequest`, `Unauthenticated`, |
108 | | -`PermissionDenied`, `JobNotFound`, `AgentNotAvailable`, `DuplicateKey`, |
109 | | -`RateLimited`, `Internal`, `HeartbeatLost`, `Backpressure`, |
110 | | -`ProtocolViolation`, `Timeout`, `ResumeWindowExpired`, |
111 | | -`LeaseSubsetViolation`, `AgentVersionNotAvailable`, `LeaseExpired`, |
112 | | -`BudgetExhausted`. Three additional codes are library-internal and never |
113 | | -appear on the wire (`UNNEGOTIATED_FEATURE`, plus the abstract |
114 | | -`Arcp::Error` base and the generic `Internal` fallback). |
| 197 | + puts "budget remaining: #{event.body.value} #{event.body.unit}" |
| 198 | +end |
115 | 199 |
|
116 | | -## Documentation |
| 200 | +begin |
| 201 | + handle.get_result(client: client) |
| 202 | +rescue Arcp::Errors::BudgetExhausted, Arcp::Errors::LeaseExpired => e |
| 203 | + # Never retryable — resubmit with a fresh lease/budget instead. |
| 204 | + warn "job ended: #{e.code} #{e.message}" |
| 205 | +end |
| 206 | +``` |
117 | 207 |
|
118 | | -See `docs/` for guides, concepts, and reference. Start with |
119 | | -`docs/getting-started.md`. |
| 208 | +### Subscribing to jobs |
120 | 209 |
|
121 | | -## Conformance |
| 210 | +Attach read-only to a job submitted elsewhere and observe its live stream (with optional history replay) without cancel authority. |
| 211 | + |
| 212 | +```ruby |
| 213 | +Sync do |
| 214 | + observer = Arcp::Client.open( |
| 215 | + transport: dashboard_transport, |
| 216 | + auth: { 'scheme' => 'bearer', 'token' => ENV.fetch('ARCP_TOKEN') }, |
| 217 | + client_name: 'dashboard' |
| 218 | + ) |
122 | 219 |
|
123 | | -Spec-to-code matrix in [CONFORMANCE.md](CONFORMANCE.md). |
| 220 | + running = observer.list_jobs(status: 'running', limit: 1).first |
| 221 | + stream = observer.subscribe_job(job_id: running.job_id, from_event_seq: 0, history: true) |
124 | 222 |
|
125 | | -## Development |
| 223 | + stream.each do |event| |
| 224 | + puts "[#{running.job_id}] #{event.kind}" |
| 225 | + end |
126 | 226 |
|
| 227 | + observer.close |
| 228 | +end |
127 | 229 | ``` |
128 | | -bundle install |
129 | | -bundle exec rake # spec + rubocop + steep |
130 | | -bundle exec rake docs # build doc tree |
| 230 | + |
| 231 | +### Error handling |
| 232 | + |
| 233 | +Catch the typed error taxonomy and respect the `retryable` flag — `LEASE_EXPIRED` and `BUDGET_EXHAUSTED` are never retryable; a naive retry fails identically. |
| 234 | + |
| 235 | +```ruby |
| 236 | +begin |
| 237 | + handle = client.submit_job(agent: 'flaky', input: {}) |
| 238 | + handle.get_result(client: client) |
| 239 | +rescue Arcp::Errors::LeaseExpired, Arcp::Errors::BudgetExhausted => e |
| 240 | + raise e # resubmit with a fresh lease / budget instead |
| 241 | +rescue Arcp::Error => e |
| 242 | + if e.retryable? |
| 243 | + # safe to retry with backoff (e.g. INTERNAL_ERROR, RATE_LIMITED, TIMEOUT) |
| 244 | + retry_with_backoff(e) |
| 245 | + else |
| 246 | + raise |
| 247 | + end |
| 248 | +end |
131 | 249 | ``` |
132 | 250 |
|
| 251 | +## Feature support |
| 252 | + |
| 253 | +ARCP features this SDK negotiates during the `hello`/`welcome` handshake: |
| 254 | + |
| 255 | +| Feature flag | Status | |
| 256 | +|---|---| |
| 257 | +| `heartbeat` | Supported | |
| 258 | +| `ack` | Supported | |
| 259 | +| `list_jobs` | Supported | |
| 260 | +| `subscribe` | Supported | |
| 261 | +| `lease_expires_at` | Supported | |
| 262 | +| `cost.budget` | Supported | |
| 263 | +| `model.use` | Supported | |
| 264 | +| `provisioned_credentials` | Supported | |
| 265 | +| `progress` | Supported | |
| 266 | +| `result_chunk` | Supported | |
| 267 | +| `agent_versions` | Supported | |
| 268 | + |
| 269 | +## Transport |
| 270 | + |
| 271 | +ARCP is transport-agnostic. This SDK ships a WebSocket transport (default), a stdio transport for in-process child runtimes, and an in-memory transport for tests. WebSocket is the default for networked runtimes; stdio is used for in-process child runtimes. Select one by constructing the corresponding transport object and passing it to `Arcp::Client.open(transport:, ...)`: `Arcp::Transport::WebSocketTransport.new(connection: ws)` for production (wrap an open `Async::WebSocket::Connection`, typically hosted under `falcon`), `Arcp::Transport::StdioTransport` for co-process agents, or `Arcp::Transport::MemoryTransport.pair` for in-process tests and embedded clients. |
| 272 | + |
| 273 | +## API reference |
| 274 | + |
| 275 | +Full API reference — every type, method, and event payload — is in [`docs/`](docs/) (YARD-generated reference under [`docs/api/`](docs/api/), rebuilt with `bundle exec rake docs`). |
| 276 | + |
| 277 | +## Versioning and compatibility |
| 278 | + |
| 279 | +This SDK speaks **ARCP v1.1 (draft)**. The SDK follows semantic versioning independently of the protocol; the protocol version it negotiates is shown above and in `session.hello`. A runtime advertising a different ARCP MAJOR is not guaranteed compatible. Feature mismatches degrade gracefully: the effective feature set is the intersection of what the client and runtime advertise, and the SDK will not use a feature outside it. |
| 280 | + |
| 281 | +## Contributing |
| 282 | + |
| 283 | +See [`CONTRIBUTING.md`](CONTRIBUTING.md). Protocol questions and proposed changes belong in the [spec repository](https://github.com/agentruntimecontrolprotocol/spec); SDK bugs and feature requests belong here. |
| 284 | + |
133 | 285 | ## License |
134 | 286 |
|
135 | | -Apache-2.0. See `LICENSE`. |
| 287 | +Apache-2.0 — see [`LICENSE`](LICENSE). |
0 commit comments