Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ v1.0.0). No spec MUST/SHOULD in §4–§16 is unimplemented.
| §6.2 Capability negotiation (intersection) | yes | `lib/arcp/session/capability_set.rb#intersect` |
| §6.2 Feature names: heartbeat, ack, list_jobs, subscribe, lease_expires_at, cost.budget, progress, result_chunk, agent_versions, model.use, provisioned_credentials | yes | `lib/arcp/session/feature.rb` |
| §6.3 session.welcome with resume_token + resume_window_sec | yes | `lib/arcp/session/welcome.rb`, `lib/arcp/runtime/session_actor.rb` |
| §6.3 Resume by last_event_seq | yes | `lib/arcp/runtime/event_log.rb` |
| §6.3 Resume by last_event_seq | deferred | n/a |
| §6.4 session.ping / session.pong heartbeats | yes | `lib/arcp/session/ping.rb`, `lib/arcp/session/pong.rb`, `lib/arcp/client.rb#start_heartbeat!` |
| §6.4 HEARTBEAT_LOST MUST NOT terminate jobs | yes | `lib/arcp/runtime/session_actor.rb` |
| §6.5 session.ack with last_processed_seq | yes | `lib/arcp/session/ack.rb`, `lib/arcp/client.rb#ack` |
Expand Down
27 changes: 10 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ ARCP itself is a transport-agnostic wire protocol for long-running AI agent jobs

## Installation

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`:
Requires Ruby 3.3 or later. The gem runs on the `socketry/async` reactor and pulls in `async-websocket` for the default networked transport. The runtime currently buffers events in memory for replay; durable persistence is not shipped yet. Add it to a `Gemfile`:

```ruby
gem 'arcp', '~> 1.0'
Expand Down Expand Up @@ -83,7 +83,7 @@ This is the whole shape of the SDK: open a session, submit work, consume an orde

ARCP organizes everything around four concerns — **identity**, **durability**, **authority**, and **observability** — expressed through five core objects:

- **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).
- **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 keeps a replay window in the runtime's in-memory event log. Transparent reconnect resume is not wired through yet; use `history: true` and `from_event_seq` when you need to replay events. Jobs outlive the session that started them. See [§6](https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md).
- **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).
- **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).
- **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).
Expand All @@ -93,9 +93,9 @@ The SDK models each of these as first-class objects; the rest of this README sho

## Guides

### Sessions and resume
### Sessions and replay

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.
Open a session, submit work, and replay buffered events from the retained log window when you need to recover missed history.

```ruby
require 'async'
Expand All @@ -108,22 +108,15 @@ Sync do
client_name: 'resumable'
)

session_id = client.session.id
resume_token = client.session.resume_token
last_seq = Hash.new(0)
handle = client.submit_job(agent: 'long-runner')
handle.subscribe(client: client).each do |event|
last_seq[handle.job_id] = event.body.respond_to?(:seq) ? event.body.seq : last_seq[handle.job_id]
puts "#{event.kind}: #{event.body.to_h}"
end

# ... transport drops ...

resumed = Arcp::Client.open(
transport: new_transport,
auth: { 'scheme' => 'bearer', 'token' => ENV.fetch('ARCP_TOKEN') },
resume: { 'token' => resume_token, 'last_event_seq' => last_seq }
)
# The runtime replays every event with event_seq > last_seq, then resumes live streaming.
replay = client.subscribe_job(job_id: handle.job_id, from_event_seq: 0, history: true)
replay.each do |event|
puts "[replay] #{event.kind}"
end
end
```

Expand Down Expand Up @@ -277,7 +270,7 @@ Full API reference — every type, method, and event payload — is in [`docs/`]

## Versioning and compatibility

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.
This SDK speaks **ARCP v1.1 (draft)**. The SDK follows semantic versioning independently of the protocol; the negotiated runtime version is available on `client.session.runtime_version`. 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.

## Contributing

Expand Down
6 changes: 3 additions & 3 deletions arcp.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ Gem::Specification.new do |spec|
spec.summary = 'Reference Ruby implementation of the Agent Runtime Control Protocol (ARCP).'
spec.description = <<~DESC
Ruby SDK for ARCP: envelope and message model, fiber-based runtime, client,
WebSocket / stdio / in-memory transports, SQLite-backed resume log,
capability negotiation, leases with budget and expiration, streamed results,
and OpenTelemetry trace propagation. Built on socketry/async.
WebSocket / stdio / in-memory transports, in-memory event buffering for
replay, capability negotiation, leases with budget and expiration, streamed
results, and OpenTelemetry trace propagation. Built on socketry/async.
DESC
spec.homepage = 'https://github.com/nficano/arpc'
spec.license = 'Apache-2.0'
Expand Down
8 changes: 4 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Arcp
JobContext # passed to handlers
LeaseManager # lease + budget accounting
SubscriptionManager # cross-session observers
EventLog # replay window
EventLog # in-memory replay window
Transport
Base / MemoryTransport / WebSocketTransport / StdioTransport
```
Expand Down Expand Up @@ -113,7 +113,7 @@ Arcp::Session::Feature::RESULT_CHUNK # 'result_chunk'
Arcp::Session::Feature::AGENT_VERSIONS # 'agent_versions'
```

`Arcp::Session::Feature::ALL` is a frozen Array of all nine.
`Arcp::Session::Feature::ALL` is a frozen Array of all eleven.

### Negotiation

Expand Down Expand Up @@ -163,8 +163,8 @@ client.submit_job(agent: 'code-refactor@1.0.0') # pins to 1.0.0
```

An unknown version raises `Arcp::Errors::AgentVersionNotAvailable` with
`details['available_versions']` populated. `AgentInventory#resolve(ref)`
can validate a ref before submit:
`details['available']` populated. `AgentInventory#resolve(ref)` can
validate a ref before submit:

```ruby
client.session.capabilities.agents.resolve('code-refactor@1.0.0')
Expand Down
2 changes: 1 addition & 1 deletion docs/diagrams/module-deps-dark.dot
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ digraph ArcpModuleDeps {
SubMgr [label="SubscriptionManager"];

EventLog [
label=<<FONT POINT-SIZE="10">EventLog</FONT><BR/><FONT POINT-SIZE="8" COLOR="#64748B">SQLite</FONT>>,
label=<<FONT POINT-SIZE="10">EventLog</FONT><BR/><FONT POINT-SIZE="8" COLOR="#64748B">In-memory</FONT>>,
shape=cylinder, fillcolor="#1E293B"
];
}
Expand Down
2 changes: 1 addition & 1 deletion docs/diagrams/module-deps-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/diagrams/module-deps-light.dot
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ digraph ArcpModuleDeps {
SubMgr [label="SubscriptionManager"];

EventLog [
label=<<FONT POINT-SIZE="10">EventLog</FONT><BR/><FONT POINT-SIZE="8" COLOR="#94A3B8">SQLite</FONT>>,
label=<<FONT POINT-SIZE="10">EventLog</FONT><BR/><FONT POINT-SIZE="8" COLOR="#94A3B8">In-memory</FONT>>,
shape=cylinder, fillcolor="#FAFBFC"
];
}
Expand Down
2 changes: 1 addition & 1 deletion docs/diagrams/module-deps-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 50 additions & 0 deletions docs/guides/agent-versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
title: Agent versioning
sdk: ruby
kind: guide
order: 13
spec_sections: [§7.5]
---

# Agent versioning

Agents are registered under a stable name and an optional set of
published versions. When a client submits `name@version`, the runtime
resolves that exact version if it exists; when the client submits only
`name`, the runtime uses the registered default.

## Register versions

```ruby
runtime.register_agent(
name: 'code-refactor',
versions: %w[1.0.0 2.0.0],
default: '2.0.0',
handler: ->(ctx) { ctx.finish(result: ctx.agent) }
)
```

## Submit a version-pinned job

```ruby
handle = client.submit_job(agent: 'code-refactor@1.0.0')
result = handle.get_result(client: client)
```

## Validate a ref first

`Arcp::Session::AgentInventory#resolve(ref)` returns the normalized
`name@version` string or `nil` if the ref is unknown. On failure, the
runtime raises `Arcp::Errors::AgentVersionNotAvailable` with
`details['available']` populated with the registered versions.

```ruby
inventory = client.session.capabilities.agents
inventory.resolve('code-refactor@1.0.0') # => "code-refactor@1.0.0"
inventory.resolve('code-refactor@9.9.9') # => nil
```

## See also

- `guides/sessions.md`
- `guides/jobs.md`
7 changes: 4 additions & 3 deletions docs/guides/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ HANDLER = lambda do |ctx|
end
```

`try_spend!` atomically decrements the lease's `BudgetCounter`. If the
balance goes negative, the runtime emits `job.error` with code
`Arcp::Runtime::LeaseManager#try_spend!` atomically decrements the
lease's `BudgetCounter` by calling `BudgetCounter#try_decrement`. If the
balance is insufficient, the runtime emits `job.error` with code
`BUDGET_EXHAUSTED`.

When spend is enforced by an upstream gateway instead of local counters,
Expand All @@ -121,7 +122,7 @@ issued key — see `guides/credentials.md`.
begin
handle.get_result(client: client)
rescue Arcp::Errors::BudgetExhausted => e
e.details # { 'currency' => 'USD', 'requested' => ..., 'remaining' => ... }
e.details # { 'currency' => 'USD', 'remaining' => '0.30' }
end
```

Expand Down
3 changes: 2 additions & 1 deletion docs/guides/leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ budget = Arcp::Lease::CostBudget.parse(['USD:1.00', 'EUR:0.50'])
budget.remaining('USD') # => BigDecimal('1.00')
```

`BudgetCounter#try_spend!` atomically decrements; overspend raises
`Arcp::Runtime::LeaseManager#try_spend!` atomically decrements the
bound counter via `BudgetCounter#try_decrement`; overspend raises
`Arcp::Errors::BudgetExhausted`. See `guides/jobs.md` for the full
spend workflow.

Expand Down
78 changes: 38 additions & 40 deletions docs/guides/resume.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,67 +8,65 @@ spec_sections: [§6.3, §6.4]

# Resume

After a transport drop, a client may reconnect and continue receiving
events for in-flight jobs. The mechanism is two pieces of state on
`session.welcome`: a `resume_token` and a `resume_window_sec`.
The Ruby SDK exposes `resume_token` and `resume_window_sec` on
`session.welcome`, but it does not yet use the `resume:` reconnect
payload to transparently restore a dropped session. What is shipped
today is an in-memory replay window for job events and subscription
history.

## Resume token
## Replay retained events

When you need missed history, open a fresh session and subscribe with
`history: true`. Use `from_event_seq: 0` for a full replay of the
retained window.

```ruby
client = Arcp::Client.open(transport: transport, auth: auth)
token = client.session.resume_token
window = client.session.resume_window_sec
```

Save `token` somewhere durable across reconnects. The runtime guarantees
the resume window for that token.

## Reconnect with last_event_seq
handle = client.submit_job(agent: 'long-runner')

```ruby
new_client = Arcp::Client.open(
transport: new_transport,
auth: auth,
resume: {
'token' => token,
'last_event_seq' => { job_id => last_seq }
}
replay = client.subscribe_job(
job_id: handle.job_id,
history: true,
from_event_seq: 0
)
```

The runtime replays every job-event with `event_seq > last_seq` from
its event log, then resumes live tailing.

## Resume window expiry

If `resume_window_sec` has elapsed since the prior session closed, the
runtime responds with `session.error` code `RESUME_WINDOW_EXPIRED`. The
client raises `Arcp::Errors::ResumeWindowExpired` from `Client.open`.
Recover by opening a fresh session and re-subscribing with `history: true`.
replay.each do |event|
puts "#{event.kind}: #{event.body.to_h}"
end
```

## Heartbeats and reconnect
## Retention window

`session.ping` / `session.pong` keep idle connections alive and surface
half-open TCP states. The runtime advertises a `heartbeat_interval_sec`
on welcome; the client schedules a ping at that cadence.
The runtime keeps buffered events in memory for the configured
`resume_window_sec` period and evicts older entries from the replay log.
If that window elapses before you subscribe, the older events are no
longer recoverable.

```ruby
runtime = Arcp::Runtime::Runtime.new(
auth_verifier: verifier,
heartbeat_interval_sec: 30 # nil to disable
heartbeat_interval_sec: 30, # nil to disable
resume_window_sec: 300
)
client = Arcp::Client.open(transport: t, auth: auth)
client.session.heartbeat_interval_sec # => 30

client.session.resume_token
client.session.resume_window_sec
```

## Heartbeats

`session.ping` / `session.pong` keep idle connections alive and surface
half-open TCP states. The runtime advertises a
`heartbeat_interval_sec` on welcome; the client schedules a ping at that
cadence.

If a peer detects N consecutive missed heartbeats it MAY close the
transport and raise `Arcp::Errors::HeartbeatLost` (`retryable? == true`).

A lost heartbeat MUST NOT terminate running jobs at the runtime. Job
state persists in the event log within the resume window; reconnecting
clients can resume via `resume_token` and `from_event_seq`.
state persists in the event log within the replay window.

## See also

- `guides/sessions.md`
- `guides/jobs.md`
- `guides/job-events.md`
4 changes: 3 additions & 1 deletion docs/guides/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,11 @@ Arcp::Session::Feature::COST_BUDGET # 'cost.budget'
Arcp::Session::Feature::PROGRESS # 'progress'
Arcp::Session::Feature::RESULT_CHUNK # 'result_chunk'
Arcp::Session::Feature::AGENT_VERSIONS # 'agent_versions'
Arcp::Session::Feature::MODEL_USE # 'model.use'
Arcp::Session::Feature::PROVISIONED_CREDENTIALS # 'provisioned_credentials'
```

`Arcp::Session::Feature::ALL` is a frozen Array of all nine.
`Arcp::Session::Feature::ALL` is a frozen Array of all eleven.

### CapabilitySet

Expand Down
Loading