diff --git a/CONFORMANCE.md b/CONFORMANCE.md index e39d432..8c2ee86 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -16,7 +16,7 @@ v1.0.0). No spec MUST/SHOULD in §4–§16 is unimplemented. | §6.1 Bearer auth scheme | yes | `lib/arcp/auth/bearer.rb` | | §6.1 Pluggable AuthScheme | yes | `lib/arcp/auth/auth_scheme.rb` | | §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 | yes | `lib/arcp/session/feature.rb` | +| §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.4 session.ping / session.pong heartbeats | yes | `lib/arcp/session/ping.rb`, `lib/arcp/session/pong.rb`, `lib/arcp/client.rb#start_heartbeat!` | @@ -51,6 +51,9 @@ v1.0.0). No spec MUST/SHOULD in §4–§16 is unimplemented. | §9.6 cost.budget capability (BigDecimal per currency) | yes | `lib/arcp/lease.rb#CostBudget` | | §9.6 BudgetCounter try_decrement | yes | `lib/arcp/lease.rb#BudgetCounter` | | §9.6 BUDGET_EXHAUSTED on overspend | yes | `lib/arcp/runtime/lease_manager.rb` | +| §9.7 model.use capability and lease checks | yes | `lib/arcp/lease.rb`, `lib/arcp/runtime/lease_manager.rb#check_model!` | +| §9.8 provisioned credential wire shape | yes | `lib/arcp/credential.rb`, `lib/arcp/job/accepted.rb` | +| §9.8 credential provisioner and revocation lifecycle | yes | `lib/arcp/credential_provisioner.rb`, `lib/arcp/runtime/credential_registry.rb`, `lib/arcp/runtime/job_manager.rb` | | §10 Delegate event kind with child lease | yes | `lib/arcp/job/event_body/delegate.rb` | | §10 LEASE_SUBSET_VIOLATION on excess | yes | `lib/arcp/lease.rb#Subsetting.bound` | | §11 trace_id propagation on envelope | yes | `lib/arcp/envelope.rb`, `lib/arcp/client.rb#send_envelope` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..09d6952 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,349 @@ +# Contributing + +## Development setup + +``` +git clone https://github.com/agentruntimecontrolprotocol/ruby-sdk +cd ruby-sdk +bundle install +bundle exec rake spec # run tests +bundle exec rubocop # lint +bundle exec steep check # type-check +``` + +## Pull requests + +- One logical change per PR. +- All tests pass on the minimum and maximum supported Ruby versions. +- RuboCop exits 0. +- `CHANGELOG.md` updated. Breaking changes bump the major version and are + flagged prominently. + +--- + +# Idiomatic Ruby Style Guide + +Authoritative style for this gem. Optimized for readability, predictable +public API surface, and Claude Code consumption. + +When this guide conflicts with personal taste, this guide wins. When it +conflicts with RuboCop defaults, this guide wins and the cop gets configured. + +--- + +## Hard Limits (Non-Negotiable) + +- Line length: aspire **80**, hard cap **100**. +- Method length: **10 lines** (excluding `def`/`end`). +- Class length: **100 lines** (excluding comments). +- Module length: **100 lines**. +- File length: **200 lines**. +- Cyclomatic complexity: **6** per method. +- Perceived complexity: **7** per method. +- ABC size: **17** per method. +- Block nesting: **3** levels. +- Method parameters: **4** (use keyword args or a `Data`/`Struct` beyond). +- Module nesting: **3** levels deep. + +When a limit is breached, **refactor — do not configure the linter to allow +exceptions**. Excess size is a design smell, not a formatting problem. + +--- + +## File Organization + +- One class or module per file. No exceptions for "small helpers". +- Filename mirrors the constant: `MyGem::HTTPClient` lives at + `lib/my_gem/http_client.rb`. +- `lib/my_gem.rb` is the entry point and does nothing but `require` and + define the top-level module. +- `lib/my_gem/version.rb` holds `VERSION = "x.y.z"` and nothing else. +- Use Zeitwerk for autoloading in any non-trivial gem. +- Group by domain, not by type. Prefer `lib/my_gem/billing/invoice.rb` over + `lib/my_gem/models/invoice.rb`. Domain folders scale; type folders don't. + +## Magic Comments + +Every `.rb` file starts with: + +```ruby +# frozen_string_literal: true +``` + +No exceptions. Add it to generators, templates, and rake tasks too. + +--- + +## Naming + +- `snake_case` for methods, variables, and files. +- `CamelCase` for classes and modules. +- `SCREAMING_SNAKE_CASE` for constants. +- Predicate methods end with `?` and return strict booleans where possible. +- Bang methods (`!`) signal mutation, danger, or raise-on-failure. Always + pair with a non-bang version unless raising is the only sensible behavior. +- Avoid `get_` and `set_` prefixes. Use attribute accessors. +- Spell names out. Abbreviate only when the abbreviation is more recognizable + than the word (`url`, `http`, `id`, `db`). +- Boolean attributes read as questions: `active?`, not `is_active`. +- Collection variables are plural: `users`, not `user_list`. + +--- + +## Module & Class Design + +- Wrap every public symbol in your top-level gem module. +- Prefer composition over inheritance. Inherit at most one level deep unless + modeling a genuine is-a hierarchy. +- Use `Module` for namespacing and stateless helpers. Use `Class` for objects + that carry state. +- Mix in `Comparable` / `Enumerable` instead of reimplementing their + contracts. +- Freeze public constants: `DEFAULT_TIMEOUT = 30` then `.freeze` mutable + literals (arrays, hashes, strings without the magic comment). +- For value objects, prefer `Data.define` (Ruby 3.2+) or `Struct` over + hand-rolled classes with `attr_reader` boilerplate. +- A class with only class methods is a module. Convert it. + +--- + +## Method Design + +- One method, one responsibility. If the name needs "and", split it. +- Required positional args come first, then keyword args. +- Use keyword arguments when a method has 3+ parameters, **or** any boolean + parameter (positional booleans are always wrong). +- Return meaningful values. Avoid returning `self` unless chaining is part + of the documented public API. +- No side effects in predicate methods. +- Methods that can fail in expected ways either return a result object or + raise a domain-specific error. Do not return `nil` ambiguously. + +```ruby +# Good +def find_user(id:) + repo.fetch(id) or raise NotFoundError, "User #{id} not found" +end + +# Bad +def find_user(id) + repo.fetch(id) # returns nil on miss — caller has to guess +end +``` + +--- + +## Error Handling + +Define an error hierarchy rooted at one class so consumers can rescue a +single type: + +```ruby +module MyGem + class Error < StandardError; end + class ConfigurationError < Error; end + class APIError < Error; end + class RateLimitError < APIError; end + class NotFoundError < APIError; end +end +``` + +- Every gem-raised error inherits from `MyGem::Error`. +- Never `rescue Exception`. Never `rescue` bare. +- Rescue the narrowest class that handles the case. +- Error messages include actionable context: IDs, URLs, expected vs got. +- Do not swallow errors silently. If suppression is required, log via a + configurable logger. +- Do not raise from initializers unless construction is genuinely + impossible. + +--- + +## Public API Discipline + +- Mark every method `public`, `private`, or `protected` explicitly in any + class that has non-public methods. +- Tag internal-but-reachable methods with `# @api private` (YARD). +- Public constants are frozen and documented. +- Keep the public surface small. Each new public method is a permanent + maintenance commitment. +- Do not monkey-patch core classes from a published gem. Use refinements + only when unavoidable, scoped to the smallest file possible. +- Never modify `Object`, `Kernel`, `Class`, or `Module`. + +--- + +## Configuration + +Single block-based entry point: + +```ruby +MyGem.configure do |c| + c.api_key = ENV.fetch("MY_GEM_KEY") + c.timeout = 10 +end +``` + +- Validate at configure time. Fail loudly on missing required keys. +- Freeze the config object after the block returns. +- Provide sensible defaults for every optional setting. +- Expose `MyGem.configuration` as a frozen reader, never a writer. + +--- + +## Dependencies + +- Minimize runtime dependencies. Each one constrains downstream users. +- Pin minimum versions (`~> 2.0`). Never pin maximum versions unless a known + break exists. +- Lazy-require optional dependencies inside the method that uses them and + raise a clear error if missing. +- Development dependencies go in the Gemfile, not the gemspec. + +--- + +## Idioms to Prefer + +- `Array(value)` — nil-safe wrap. +- `Hash#dig` — nested access without nil checks. +- `Object#then` / `yield_self` — readable transformations. +- `Object#tap` — side effects mid-chain. +- Safe navigation `&.` — one level only. Chains of `&.` hide design + problems. +- Pattern matching (`case/in`) for structural conditionals on Ruby 3+. +- `Set` over `Array#include?` for membership when the collection grows past + ~10 elements. +- `String#<<` over `+=` in loops. +- Heredocs with `<<~` (squiggly) for multiline strings. +- `each_with_object` over `inject` when accumulating into a mutable + collection. +- Memoize with `@x ||= compute` **only when** the value cannot legitimately + be `nil` or `false`. Otherwise use + `defined?(@x) ? @x : (@x = compute)`. + +--- + +## Anti-Patterns (Forbidden) + +- Class variables (`@@var`). Use class instance variables or a registry. +- Global variables (`$var`) outside genuine globals (`$stdout`, `$stderr`). +- `method_missing` without a paired `respond_to_missing?`. +- `eval`, `class_eval` with strings, `instance_eval` with strings. +- `rescue Exception` or bare `rescue`. +- Rescuing in initializers. +- `alias_method_chain`-style wrapping. Use `Module#prepend`. +- Monkey-patching core classes from a published gem. +- Long parameter lists hidden as `**opts` with no documentation. +- Returning different shapes from the same method (`String` or `nil` or + `Array`). Pick one return type and stick to it. +- `def self.method` scattered through a class. Group under + `class << self`. + +--- + +## Documentation + +- Every public class, module, and method has a YARD docstring. +- Document `@param`, `@return`, `@raise`, and provide at least one + `@example` for non-trivial methods. +- Keep `README.md` runnable: every snippet must execute against the current + version. CI should verify this where practical. +- Maintain `CHANGELOG.md` following the Keep a Changelog format. +- Document breaking changes prominently and bump major versions. + +--- + +## Testing + +- One test framework per gem. RSpec or Minitest, not both. +- Test the public API exclusively. Private methods are tested through their + callers. +- One logical assertion per test where practical. Group with + `aggregate_failures` (RSpec) when assertions are about one outcome. +- Stub external HTTP with WebMock or VCR. +- Run tests under the lowest and highest supported Ruby versions in CI. +- No `sleep` in tests. Use proper synchronization or time travel. + +--- + +## RuboCop Baseline + +Ship `.rubocop.yml` with: + +```yaml +AllCops: + NewCops: enable + TargetRubyVersion: 3.1 + SuggestExtensions: false + +Layout/LineLength: + Max: 100 + +Metrics/MethodLength: + Max: 10 +Metrics/ClassLength: + Max: 100 +Metrics/ModuleLength: + Max: 100 +Metrics/BlockLength: + Max: 15 +Metrics/AbcSize: + Max: 17 +Metrics/CyclomaticComplexity: + Max: 6 +Metrics/PerceivedComplexity: + Max: 7 +Metrics/ParameterLists: + Max: 4 + CountKeywordArgs: false + +Style/Documentation: + Enabled: true +Style/FrozenStringLiteralComment: + EnforcedStyle: always +``` + +Treat violations as build failures. Refactor first. Disable a cop only with +an inline comment justifying the exception. + +--- + +## Reducing Complexity (Refactor Patterns) + +When a method exceeds limits, apply these in order: + +1. **Extract Method.** Pull cohesive lines into a named private method. +2. **Replace Conditional with Polymorphism.** A long `case` on a type + becomes classes implementing a shared interface. +3. **Introduce Parameter Object.** Group related params into `Data` or + `Struct`. +4. **Replace Temp with Query.** Turn intermediate variables into methods. +5. **Decompose Conditional.** Extract the predicate AND each branch into + named methods. +6. **Move Method.** If a method uses another object's data more than its + own, it belongs there. +7. **Replace Loop with Pipeline.** Chain `map` / `select` / `reduce` + instead of stateful loops. +8. **Guard Clauses.** Replace nested `if` with early returns. + +If a class breaches 100 lines, look for a second class trying to escape. +Most overlong classes are hiding a collaborator. Names that signal this: +`*Manager`, `*Handler`, `*Processor`, `*Helper`, `*Utils`. + +If a file breaches 200 lines, the class inside has already breached the +class limit. Fix the class, the file follows. + +--- + +## Quick Checklist Before Merge + +- [ ] `# frozen_string_literal: true` on every file. +- [ ] No file > 200 lines, no class > 100, no method > 10. +- [ ] No line > 100 chars; most lines ≤ 80. +- [ ] Every public method has a YARD docstring. +- [ ] Every gem-raised error inherits from `Arcp::Error`. +- [ ] No `@@`, no bare `rescue`, no `rescue Exception`. +- [ ] No monkey patches of core classes. +- [ ] RuboCop exits 0. +- [ ] All tests pass on min and max supported Ruby. +- [ ] CHANGELOG updated. Breaking changes flagged. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0b51a89 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,63 @@ +--- +title: Documentation +sdk: ruby +kind: index +order: 0 +--- + +# ARCP Ruby SDK documentation + +## Start here + +| Doc | Description | +| --- | --- | +| [Getting started](getting-started.md) | Install, minimal in-process example, first job | +| [Transports](transports.md) | MemoryTransport, WebSocketTransport, StdioTransport | +| [Architecture](architecture.md) | Namespace map, concurrency model, event flow | +| [Troubleshooting](troubleshooting.md) | Common errors and fixes | + +## Guides + +| Guide | Spec | Description | +| --- | --- | --- | +| [Sessions](guides/sessions.md) | §6.1–§6.2 | Session lifecycle, wire shape, capability negotiation | +| [Authentication](guides/auth.md) | §6.1 | Bearer verifier, custom AuthScheme | +| [Resume](guides/resume.md) | §6.3–§6.4 | Transport reconnect, resume token, heartbeats | +| [Jobs](guides/jobs.md) | §7, §9.6 | FSM, Handle API, idempotency, cancellation, cost budgets | +| [Job events](guides/job-events.md) | §8, §7.6 | EventKind table, pattern-match dispatch, subscribe, result streaming | +| [Leases](guides/leases.md) | §9 | Capabilities, expires_at, cost.budget, model.use, subsetting | +| [Delegation](guides/delegation.md) | §10, §9.4 | Delegate event, child lease subset rules | +| [Agent versioning](architecture.md#agent-versioning) | §7.5 | name@version pins, AgentVersionNotAvailable | +| [Provisioned credentials](guides/credentials.md) | §9.7–§9.8 | InMemoryProvisioner, lease-scoped keys, rotation/revocation | +| [Deployment](guides/deployment.md) | — | Falcon + WebSocket, process supervision | +| [Observability](guides/observability.md) | §11 | Trace::Context, OpenTelemetry bridge | +| [Errors](guides/errors.md) | §12 | Wire codes table, Arcp::Errors.for, retryable? | +| [Vendor extensions](guides/vendor-extensions.md) | §15 | x-vendor.* kinds, emit and receive | +| [Recipes](recipes.md) | — | Common patterns (submit, stream, cancel, budgets, credentials, resume) | + +## Diagrams + +| File | Description | +| --- | --- | +| [diagrams/session-fsm-light.svg](diagrams/session-fsm-light.svg) | Session lifecycle state machine | +| [diagrams/job-fsm-light.svg](diagrams/job-fsm-light.svg) | Job lifecycle state machine | +| [diagrams/capability-negotiation-light.svg](diagrams/capability-negotiation-light.svg) | Capability negotiation handshake | +| [diagrams/heartbeat-flow-light.svg](diagrams/heartbeat-flow-light.svg) | Heartbeat ping/pong flow | +| [diagrams/result-chunk-sequence-light.svg](diagrams/result-chunk-sequence-light.svg) | Result streaming chunk sequence | +| [diagrams/module-deps-light.svg](diagrams/module-deps-light.svg) | Module dependency graph | + +Generate diagrams from source: + +``` +bash bin/render-diagrams.sh +``` + +## CLI + +There is no standalone CLI for this SDK. Runtime management and transport +operations are performed through the Ruby API. See `getting-started.md` +and `guides/deployment.md`. + +## Conformance + +See [conformance.md](conformance.md) for the full spec-to-code matrix. diff --git a/docs/architecture.md b/docs/architecture.md index 6ba1e17..8490ba5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,3 +92,86 @@ and per-job stream queues. The two never share memory across a real network. Same-process tests use `MemoryTransport.pair` so a single Ruby process can host both. + +## Capabilities + +A session's `CapabilitySet` advertises `features`, `encodings`, and +(server-side) an `AgentInventory`. The handshake intersects client and +server sets; the result is stored on `client.session.capabilities`. + +### Feature constants + +```ruby +Arcp::Session::Feature::HEARTBEAT # 'heartbeat' +Arcp::Session::Feature::ACK # 'ack' +Arcp::Session::Feature::LIST_JOBS # 'list_jobs' +Arcp::Session::Feature::SUBSCRIBE # 'subscribe' +Arcp::Session::Feature::LEASE_EXPIRES_AT # 'lease_expires_at' +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::ALL` is a frozen Array of all nine. + +### Negotiation + +```ruby +client_caps = Arcp::Session::CapabilitySet.local( + features: ['heartbeat', 'list_jobs'], + encodings: ['utf8'] +) +server_caps = Arcp::Session::CapabilitySet.local( + features: ['heartbeat', 'subscribe'], + encodings: ['utf8', 'base64'] +) + +effective = client_caps.intersect(server_caps) +effective.features # ['heartbeat'] -- intersection +effective.encodings # ['utf8'] -- intersection +``` + +`Arcp::Client.open` performs this intersection automatically. + +### Checking a negotiated feature + +```ruby +if client.session.supports?(Arcp::Session::Feature::SUBSCRIBE) + client.subscribe_job(job_id: id, history: true, from_event_seq: 0) +end +``` + +Calling a feature method without the negotiated capability raises +`Arcp::Errors::UnnegotiatedFeature`. + +## Agent versioning + +Agents declare a fixed set of versions and one default. Clients submit +either by name (uses the default) or by `name@version` (pin). + +```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) } +) + +client.submit_job(agent: 'code-refactor') # resolves to 2.0.0 +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: + +```ruby +client.session.capabilities.agents.resolve('code-refactor@1.0.0') +# => 'code-refactor@1.0.0' +client.session.capabilities.agents.resolve('code-refactor@9.9.9') +# => nil +``` + +See [guides/agent-versioning.md](guides/agent-versioning.md) for the full +versioning guide. diff --git a/docs/concepts/events.md b/docs/concepts/events.md deleted file mode 100644 index f1b2bc9..0000000 --- a/docs/concepts/events.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: Events -sdk: ruby -kind: concept -order: 13 -spec_sections: [§8] ---- - -# Events - -## What - -A `job.event` envelope carries one `Arcp::Job::Event { kind, body }` -plus a monotonic `event_seq`. Events are ordered per-job and replayed -verbatim from the runtime's event log on subscribe-with-history. - -## EventKind - -``` -progress EventBody::Progress current, total, units, message -result_chunk EventBody::ResultChunk result_id, chunk_seq, data, encoding, more -log EventBody::Log level, message, fields -thought EventBody::Thought text -tool_call EventBody::ToolCall call_id, tool, args -tool_result EventBody::ToolResult call_id, result, error -status EventBody::Status phase, message -metric EventBody::Metric name, value, unit -trace_span EventBody::TraceSpan span_id, name, started_at, ended_at, attributes -delegate EventBody::Delegate child_job_id, agent, lease -``` - -Unknown kinds (e.g. `x-vendor.acme.progress`) round-trip as a frozen -`Hash` body. - -## Pattern-match dispatch - -```ruby -handle.subscribe(client: client).each do |event| - case event - in { kind: Arcp::Job::EventKind::PROGRESS, body: { current:, total: } } - puts "#{current}/#{total}" - in { kind: Arcp::Job::EventKind::LOG, body: { level:, message: } } - puts "[#{level}] #{message}" - in { kind: Arcp::Job::EventKind::RESULT_CHUNK, body: } - write_chunk(body.decoded) - else - # ignore unknown / vendor kinds - end -end -``` - -## See also - -- `guides/result-streaming.md` -- `concepts/vendor-extensions.md` diff --git a/docs/concepts/heartbeats.md b/docs/concepts/heartbeats.md deleted file mode 100644 index 9d35c77..0000000 --- a/docs/concepts/heartbeats.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Heartbeats -sdk: ruby -kind: concept -order: 14 -spec_sections: [§6.4] ---- - -# Heartbeats - -## What - -`session.ping` and `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. - -## Cadence - -If the negotiated capabilities include `heartbeat` and -`heartbeat_interval_sec` is non-nil, the client starts a heartbeat fiber -that sends `session.ping` every interval. Any inbound envelope counts as -liveness — explicit pongs are sent in reply to pings but are otherwise -not required. - -## In Ruby - -```ruby -runtime = Arcp::Runtime::Runtime.new( - auth_verifier: verifier, - heartbeat_interval_sec: 30 # nil to disable -) -client = Arcp::Client.open(transport: t, auth: auth) -client.session.heartbeat_interval_sec # => 30 -``` - -## HEARTBEAT_LOST - -If a peer detects N consecutive missed heartbeats it MAY close the -transport and raise `Arcp::Errors::HeartbeatLost`. This error is -`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`. - -## See also - -- `concepts/resume.md` -- `concepts/sessions.md` diff --git a/docs/concepts/jobs.md b/docs/concepts/jobs.md deleted file mode 100644 index 2605a8f..0000000 --- a/docs/concepts/jobs.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Jobs -sdk: ruby -kind: concept -order: 11 -spec_sections: [§7] ---- - -# Jobs - -## What - -A job is one invocation of one agent within an open session. It has a -deterministic lifecycle: `job.submit` -> `job.accepted` -> stream of -`job.event` -> terminal `job.result` or `job.error`. - -## FSM - -``` -submitted - -> accepted (runtime allocated job_id and lease) - -> running (first event emitted by handler) - -> succeeded (job.result terminal) - -> failed (job.error terminal) - -> cancelled (job.error with code=CANCELLED) -``` - -A job is terminal on `succeeded`, `failed`, or `cancelled`. Subscriptions -end at the terminal envelope. The `event_seq` on each `job.event` is -monotonic per-job, starting at 1. - -## In Ruby - -```ruby -handle = client.submit_job( - agent: 'echo', - input: { 'msg' => 'hi' }, - idempotency_key: 'req-42', - max_runtime_sec: 60 -) -handle.job_id # String -handle.lease # Arcp::Lease::Lease (issued by runtime) -handle.submitted_at # ISO-8601 UTC - -handle.subscribe(client: client).each { |ev| ... } -result = handle.get_result(client: client) -result.final_status # 'success' -result.result # whatever the handler passed to ctx.finish(result:) -``` - -## Idempotency - -Submitting twice with the same `idempotency_key` resolves to the same -job_id. A different payload under an existing key raises -`Arcp::Errors::DuplicateKey`. - -## See also - -- `concepts/events.md` -- `concepts/leases.md` -- `concepts/subscribe.md` diff --git a/docs/concepts/resume.md b/docs/concepts/resume.md deleted file mode 100644 index f970bb1..0000000 --- a/docs/concepts/resume.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -title: Resume -sdk: ruby -kind: concept -order: 17 -spec_sections: [§6.3] ---- - -# Resume - -## What - -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`. - -## Resume token - -```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 - -```ruby -new_client = Arcp::Client.open( - transport: new_transport, - auth: auth, - resume: { 'token' => token, 'last_event_seq' => { job_id => last_seq } } -) -``` - -The runtime replays every job-event with `event_seq > last_seq` from -its event log, then resumes live tailing. - -## Resume window - -If the `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`. - -## See also - -- `concepts/heartbeats.md` -- `concepts/subscribe.md` diff --git a/docs/concepts/sessions.md b/docs/concepts/sessions.md deleted file mode 100644 index 89ae66a..0000000 --- a/docs/concepts/sessions.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -title: Sessions -sdk: ruby -kind: concept -order: 10 -spec_sections: [§6.1, §6.2] ---- - -# Sessions - -## What - -A session is the unit of authenticated, capability-negotiated connection -between one client and one runtime. The client sends `session.hello`, -the runtime responds with `session.welcome`, and from that point all -subsequent envelopes carry the same `session_id`. - -## Wire shape - -```json -// session.hello -{ "type": "session.hello", - "payload": { - "client_name": "my-app", - "client_version": "1.2.3", - "auth": { "scheme": "bearer", "token": "..." }, - "capabilities": { "features": ["heartbeat","ack","list_jobs"], "encodings": ["utf8","base64"] } - } -} - -// session.welcome -{ "type": "session.welcome", - "payload": { - "runtime_version": "1.0.0", - "capabilities": { "features": [...], "encodings": [...], "agents": [...] }, - "heartbeat_interval_sec": 30, - "resume_token": "...", - "resume_window_sec": 300 - } -} -``` - -## In Ruby - -```ruby -caps = Arcp::Session::CapabilitySet.local( - features: [Arcp::Session::Feature::HEARTBEAT, - Arcp::Session::Feature::LIST_JOBS, - Arcp::Session::Feature::SUBSCRIBE] -) -client = Arcp::Client.open(transport: transport, auth: { 'scheme' => 'bearer', 'token' => 'demo' }, capabilities: caps) -client.session.supports?(Arcp::Session::Feature::LIST_JOBS) # => true -client.session.capabilities.agents # => Arcp::Session::AgentInventory -``` - -The post-welcome snapshot is an `Arcp::Session::Info` value: `id`, -`runtime_version`, `capabilities` (intersected), `agents` (the runtime's -inventory), `heartbeat_interval_sec`, `resume_token`, -`resume_window_sec`. - -## Lifecycle - -- `opening` — `session.hello` sent, awaiting reply -- `open` — `session.welcome` received, normal traffic -- `closing` — local `close()` called, `session.bye` sent -- `closed` — transport closed, all queues drained - -`session.error` at any stage maps to one of `Arcp::Errors::*` and is -raised from `Client.open` or the current call. - -## See also - -- `concepts/auth.md` -- `concepts/heartbeats.md` -- `reference/capabilities.md` diff --git a/docs/concepts/subscribe.md b/docs/concepts/subscribe.md deleted file mode 100644 index db8edf2..0000000 --- a/docs/concepts/subscribe.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: Subscribe -sdk: ruby -kind: concept -order: 15 -spec_sections: [§7.6] ---- - -# Subscribe - -## What - -`subscribe_job` lets any session — including a session other than the -one that submitted the job — observe a job's event stream. With -`history: true` and `from_event_seq: 0`, the runtime replays the event -log from the start before tailing live events. - -## Cross-session observation - -```ruby -# session A submits -handle = client_a.submit_job(agent: 'worker') - -# session B observes -events = client_b.subscribe_job( - job_id: handle.job_id, - history: true, - from_event_seq: 0 -).take(3) -``` - -## History replay - -The runtime maintains an `EventLog` with a `resume_window_sec` retention. -Replay is sourced from this log; events evicted past the window are not -recoverable. Subscribe before that window elapses, or accept partial -replay. - -## No cancel from a subscriber - -A subscriber handle observes but cannot cancel. Cancellation is reserved -for the session that owns the job — calling `cancel_job` on an -observer-side handle raises a permission error from the runtime. - -## See also - -- `concepts/jobs.md` -- `concepts/resume.md` diff --git a/docs/conformance.md b/docs/conformance.md new file mode 100644 index 0000000..2918ccf --- /dev/null +++ b/docs/conformance.md @@ -0,0 +1,16 @@ +--- +title: Conformance +sdk: ruby +kind: reference +order: 99 +--- + +# Conformance + +The full spec-to-code matrix lives at the repository root: + +- [`CONFORMANCE.md`](../CONFORMANCE.md) + +It maps every numbered spec section (§4–§16) to the Ruby class or method +that implements it, and lists each `MUST`/`SHOULD`/`MAY` assertion with +its implementation status (`PASS`, `N/A`, or a note). diff --git a/docs/getting-started.md b/docs/getting-started.md index ba13314..31920b0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,41 +11,77 @@ spec_sections: [§6, §7] Install `arcp` (Ruby 3.3+): ```ruby +# Gemfile gem 'arcp', '~> 1.0' ``` +``` +bundle install +``` + ## Minimal in-process example +The handler, runtime, transport, and client all run in one process backed +by `MemoryTransport`. This is the fastest way to validate an integration +without a network or external server. + ```ruby require 'async' require 'arcp' +ECHO_HANDLER = lambda do |ctx| + ctx.log(level: 'info', message: "echoing #{ctx.input.inspect}") + ctx.progress(current: 1, total: 1, units: 'message') + ctx.finish(result: { 'echoed' => ctx.input }) +end + Sync do + # 1. Build the runtime. runtime = Arcp::Runtime::Runtime.new( auth_verifier: Arcp::Auth::Bearer.from_token('demo', principal_id: 'alice'), heartbeat_interval_sec: nil ) runtime.register_agent( name: 'echo', versions: ['1.0.0'], default: '1.0.0', - handler: ->(ctx) { ctx.finish(result: { 'echoed' => ctx.input }) } + handler: ECHO_HANDLER ) + # 2. Wire an in-process transport pair. server_t, client_t = Arcp::Transport::MemoryTransport.pair server = Async { runtime.accept(server_t) } + + # 3. Open a session. client = Arcp::Client.open( transport: client_t, - auth: { 'scheme' => 'bearer', 'token' => 'demo' } + auth: { 'scheme' => 'bearer', 'token' => 'demo' }, + client_name: 'quickstart' ) + # 4. Submit, observe, collect. handle = client.submit_job(agent: 'echo', input: { 'msg' => 'hi' }) events = handle.subscribe(client: client).to_a result = handle.get_result(client: client) + puts "job_id: #{handle.job_id}" + puts "events: #{events.map(&:kind).inspect}" + puts "final_status: #{result.final_status}" + puts "result: #{result.result.inspect}" + + # 5. Tear down. client.close server.stop end ``` +## What you should see + +``` +job_id: job_... +events: ["log", "progress"] +final_status: success +result: {"echoed"=>{"msg"=>"hi"}} +``` + ## Submit ```ruby @@ -86,6 +122,8 @@ Returns a lazy `Enumerator` that walks `next_cursor` automatically. ## Next -- `architecture.md` — namespace map and concurrency model. -- `transports.md` — which transport to pick. -- `concepts/sessions.md`, `concepts/jobs.md` — protocol concepts. +- [architecture.md](architecture.md) — namespace map and concurrency model. +- [transports.md](transports.md) — which transport to pick. +- [guides/sessions.md](guides/sessions.md) — session lifecycle and capability negotiation. +- [guides/jobs.md](guides/jobs.md) — FSM, Handle API, idempotency, cancellation. +- [guides/deployment.md](guides/deployment.md) — running under Falcon over WebSocket. diff --git a/docs/guides/agent-versioning.md b/docs/guides/agent-versioning.md deleted file mode 100644 index dd3cba1..0000000 --- a/docs/guides/agent-versioning.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: Agent versioning -sdk: ruby -kind: guide -order: 20 -spec_sections: [§7.5, §12] ---- - -# Agent versioning - -Agents declare a fixed set of versions and one default. Clients submit -either by name (defaults) or by `name@version` (pin). - -## Register multiple versions - -```ruby -handler = ->(ctx) { ctx.finish(result: ctx.agent) } - -runtime.register_agent( - name: 'code-refactor', - versions: %w[1.0.0 2.0.0], - default: '2.0.0', - handler: handler -) -``` - -The same handler serves both versions; the handler reads `ctx.agent` to -discover which `name@version` ref the runtime resolved. - -## Submit with defaults - -```ruby -client.submit_job(agent: 'code-refactor') -# resolves to 'code-refactor@2.0.0' -``` - -## Submit pinned - -```ruby -client.submit_job(agent: 'code-refactor@1.0.0') -# runs the 1.0.0 path -``` - -## Unknown version - -```ruby -begin - client.submit_job(agent: 'code-refactor@9.9.9') -rescue Arcp::Errors::AgentVersionNotAvailable => e - e.details['available_versions'] # => ['1.0.0', '2.0.0'] -end -``` - -`Arcp::Errors::AgentVersionNotAvailable` carries the available versions -in `details` for fallback logic. - -## Resolution helper - -`Arcp::Session::AgentInventory#resolve(ref)` returns the canonical -`name@version` string or `nil`. Use it client-side to validate a ref -before submit: - -```ruby -client.session.capabilities.agents.resolve('code-refactor@1.0.0') -# => 'code-refactor@1.0.0' -client.session.capabilities.agents.resolve('code-refactor@9.9.9') -# => nil -``` diff --git a/docs/concepts/auth.md b/docs/guides/auth.md similarity index 84% rename from docs/concepts/auth.md rename to docs/guides/auth.md index 3e51820..e433d4b 100644 --- a/docs/concepts/auth.md +++ b/docs/guides/auth.md @@ -1,15 +1,13 @@ --- title: Authentication sdk: ruby -kind: concept -order: 18 +kind: guide +order: 11 spec_sections: [§6.1] --- # Authentication -## What - `session.hello` carries an `auth` block. The runtime's `AuthScheme` implementation inspects it and returns an `Arcp::Auth::Principal` or `nil` (reject). @@ -22,8 +20,12 @@ strings to principals. ```ruby verifier = Arcp::Auth::Bearer.new( tokens: { - 'tk-alice' => Arcp::Auth::Principal.new(id: 'alice', name: 'Alice', scopes: ['jobs.submit'].freeze), - 'tk-bob' => Arcp::Auth::Principal.new(id: 'bob', name: 'Bob', scopes: [].freeze) + 'tk-alice' => Arcp::Auth::Principal.new( + id: 'alice', name: 'Alice', scopes: ['jobs.submit'].freeze + ), + 'tk-bob' => Arcp::Auth::Principal.new( + id: 'bob', name: 'Bob', scopes: [].freeze + ) } ) @@ -48,10 +50,13 @@ class HmacAuth def verify(token) return nil if token.nil? + principal, signature = token.split(':', 2) return nil if principal.nil? || signature.nil? + expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('AUTH_SECRET'), principal) return nil unless OpenSSL.fixed_length_secure_compare(expected, signature) + Arcp::Auth::Principal.new(id: principal, name: principal, scopes: [].freeze) end end @@ -64,4 +69,4 @@ raised on the client as `Arcp::Errors::Unauthenticated`. ## See also -- `concepts/sessions.md` +- `guides/sessions.md` diff --git a/docs/guides/budgets.md b/docs/guides/budgets.md deleted file mode 100644 index 95ddb46..0000000 --- a/docs/guides/budgets.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Budgets -sdk: ruby -kind: guide -order: 21 -spec_sections: [§9.6, §12] ---- - -# Budgets - -The `cost.budget` capability caps spend per currency. Amounts are -`BigDecimal` end-to-end — no float drift on the wire or in the -counter. - -## Request a budget at submit - -```ruby -handle = client.submit_job( - agent: 'shopper', - lease_request: Arcp::Lease::LeaseRequest.new( - capabilities: ['cost.spend'], - budget: Arcp::Lease::CostBudget.parse(['USD:1.00']), - expires_at: nil - ) -) -``` - -The wire form is a list of `currency:amount` strings (`['USD:1.00']`). -`CostBudget.parse` round-trips through `BigDecimal` and back via -`#to_a`. - -## Spend from a handler - -```ruby -HANDLER = lambda do |ctx| - lm = $arcp_runtime.lease_manager - [BigDecimal('0.42'), BigDecimal('0.70')].each do |amount| - ctx.metric(name: 'cost.search', value: amount.to_s('F'), unit: 'USD') - lm.try_spend!(ctx.job_id, 'USD', amount) - end - ctx.finish(result: 'spent') -end -``` - -`try_spend!` atomically decrements the lease's `BudgetCounter`. If the -balance goes negative, the runtime emits `job.error` with code -`BUDGET_EXHAUSTED`. - -## Client-side exhaustion - -```ruby -begin - handle.get_result(client: client) -rescue Arcp::Errors::BudgetExhausted => e - e.details # { 'currency' => 'USD', 'requested' => ..., 'remaining' => ... } -end -``` - -## Inspect remaining - -```ruby -counter = $arcp_runtime.lease_manager.counter(job_id) -counter.remaining # { 'USD' => BigDecimal('0.30') } -counter.get('USD') # => BigDecimal('0.30') -``` diff --git a/docs/guides/credentials.md b/docs/guides/credentials.md new file mode 100644 index 0000000..a65e55a --- /dev/null +++ b/docs/guides/credentials.md @@ -0,0 +1,122 @@ +--- +title: Provisioned credentials +sdk: ruby +kind: guide +order: 22 +spec_sections: [§9.7, §9.8] +--- + +# Provisioned credentials + +Provisioned credentials let a runtime mint short-lived upstream keys for a +job after the lease is finalized. The key is returned only on +`job.accepted`, scoped by the lease, and revoked when the job terminates. + +## Configure the runtime + +```ruby +provisioner = Arcp::Credentials::InMemoryProvisioner.new( + endpoint: 'https://llm-gateway.example/v1', + profile: 'openai' +) + +runtime = Arcp::Runtime::Runtime.new( + auth_verifier: auth, + credential_provisioner: provisioner, + credential_store: Arcp::Credentials::InMemoryStore.new +) +``` + +When a provisioner is configured, the runtime advertises the +`model.use` and `provisioned_credentials` features during capability +negotiation. Without a provisioner, both features are omitted. + +## Request model access + +```ruby +handle = client.submit_job( + agent: 'gateway-caller', + lease_request: Arcp::Lease::LeaseRequest.new( + capabilities: ['cost.spend'], + budget: Arcp::Lease::CostBudget.parse(['USD:1.00']), + model_use: ['tier-fast/*'] + ) +) + +credential = handle.credential_for(endpoint: 'https://llm-gateway.example/v1') +``` + +The runtime copies `cost.budget`, `model.use`, and `expires_at` into the +credential constraints so an upstream gateway can enforce the same bounds. + +## Implement a provisioner + +```ruby +class LiteLLMProvisioner + include Arcp::CredentialProvisioner + + def issue(lease:, job_id:, agent:, principal_id:) + response = generate_litellm_key( + budget: lease.budget&.to_a, + models: lease.model_use, + expires_at: lease.expires_at + ) + + [ + Arcp::Credential.new( + id: response.fetch('key_alias'), + scheme: Arcp::Credential::SCHEME_BEARER, + value: response.fetch('key'), + endpoint: 'https://llm-gateway.example/v1', + profile: 'openai', + constraints: { + 'cost.budget' => lease.budget&.to_a, + 'model.use' => lease.model_use, + 'expires_at' => lease.expires_at + }.compact + ) + ] + end + + def revoke(credential_id:) + delete_litellm_key(credential_id) + end +end +``` + +Vendor-specific HTTP clients should live outside the core gem. The SDK only +defines the interface and value objects. + +When an upstream gateway reports budget exhaustion, map it back to the ARCP +error boundary: + +```ruby +begin + call_gateway(credential) +rescue StandardError => e + raise Arcp::Credentials.translate_upstream_error(e) +end +``` + +## Rotation and revocation + +Agents can rotate a credential value mid-job: + +```ruby +ctx.rotate_credential(id: 'cred_job_123_0', new_value: 'sk-new-value') +``` + +That emits a `status` event with `phase: 'credential_rotated'` and a +`fields` hash containing the new `{ id, value }`. Treat this event as +secret-bearing data. + +The runtime revokes outstanding credential ids on success, error, +cancellation, and timeout. `CredentialRegistry` retries transient revoke +failures once and keeps any failed id in the configured store for later +reconciliation. + +## Security notes + +`Credential#to_h` is the wire representation and includes `value`. +Use `Credential#to_redacted_h` for logs, metrics, and examples. +`session.list_jobs` summaries never include credentials. diff --git a/docs/concepts/delegation.md b/docs/guides/delegation.md similarity index 70% rename from docs/concepts/delegation.md rename to docs/guides/delegation.md index 283b7fe..d4593df 100644 --- a/docs/concepts/delegation.md +++ b/docs/guides/delegation.md @@ -1,30 +1,28 @@ --- title: Delegation sdk: ruby -kind: concept -order: 16 +kind: guide +order: 23 spec_sections: [§10, §9.4] --- # Delegation -## What - A handler can spawn child work by emitting a `delegate` event carrying a -child job_id, agent reference, and a child lease. The child lease MUST +child `job_id`, agent reference, and a child lease. The child lease MUST be a strict subset of the parent's lease. ## Delegate event ```ruby -parent_lease = $arcp_runtime.lease_manager.get(ctx.job_id) +parent_lease = $arcp_runtime.lease_manager.get(ctx.job_id) child_request = Arcp::Lease::LeaseRequest.new( capabilities: ['compute.read'], - budget: Arcp::Lease::CostBudget.parse(['USD:1.00']), - expires_at: nil + budget: Arcp::Lease::CostBudget.parse(['USD:1.00']), + expires_at: nil ) child_lease = Arcp::Lease::Subsetting.bound( - parent: parent_lease, + parent: parent_lease, request: child_request ) @@ -32,8 +30,8 @@ ctx.emit( kind: Arcp::Job::EventKind::DELEGATE, body: Arcp::Job::EventBody::Delegate.new( child_job_id: "child_#{ctx.job_id}", - agent: 'child', - lease: child_lease + agent: 'child', + lease: child_lease ) ) ``` @@ -50,5 +48,5 @@ Any violation raises `Arcp::Errors::LeaseSubsetViolation`. ## See also -- `concepts/leases.md` -- `guides/budgets.md` +- `guides/leases.md` +- `guides/jobs.md` diff --git a/docs/reference/errors.md b/docs/guides/errors.md similarity index 94% rename from docs/reference/errors.md rename to docs/guides/errors.md index 43c9699..cd575c1 100644 --- a/docs/reference/errors.md +++ b/docs/guides/errors.md @@ -1,8 +1,8 @@ --- title: Errors sdk: ruby -kind: reference -order: 50 +kind: guide +order: 40 spec_sections: [§12] --- @@ -61,7 +61,11 @@ end ## Building from a wire payload ```ruby -err = Arcp::Errors.for('LEASE_EXPIRED', message: 'lease expired', details: { 'lease_id' => 'lse_...' }) +err = Arcp::Errors.for( + 'LEASE_EXPIRED', + message: 'lease expired', + details: { 'lease_id' => 'lse_...' } +) raise err ``` diff --git a/docs/guides/job-events.md b/docs/guides/job-events.md new file mode 100644 index 0000000..8cf8f1a --- /dev/null +++ b/docs/guides/job-events.md @@ -0,0 +1,145 @@ +--- +title: Job events +sdk: ruby +kind: guide +order: 21 +spec_sections: [§8, §7.6] +--- + +# Job events + +A `job.event` envelope carries one `Arcp::Job::Event { kind, body }` +plus a monotonic `event_seq`. Events are ordered per-job and replayed +verbatim from the runtime's event log on subscribe-with-history. + +## EventKind + +``` +progress EventBody::Progress current, total, units, message +result_chunk EventBody::ResultChunk result_id, chunk_seq, data, encoding, more +log EventBody::Log level, message, fields +thought EventBody::Thought text +tool_call EventBody::ToolCall call_id, tool, args +tool_result EventBody::ToolResult call_id, result, error +status EventBody::Status phase, message +metric EventBody::Metric name, value, unit +trace_span EventBody::TraceSpan span_id, name, started_at, ended_at, attributes +delegate EventBody::Delegate child_job_id, agent, lease +``` + +Unknown kinds (e.g. `x-vendor.acme.progress`) round-trip as a frozen +`Hash` body. + +## Pattern-match dispatch + +```ruby +handle.subscribe(client: client).each do |event| + case event + in { kind: Arcp::Job::EventKind::PROGRESS, body: { current:, total: } } + puts "#{current}/#{total}" + in { kind: Arcp::Job::EventKind::LOG, body: { level:, message: } } + puts "[#{level}] #{message}" + in { kind: Arcp::Job::EventKind::RESULT_CHUNK, body: } + write_chunk(body.decoded) + else + # ignore unknown / vendor kinds + end +end +``` + +## Subscribe + +`subscribe_job` lets any session — including a session other than the +one that submitted the job — observe a job's event stream. With +`history: true` and `from_event_seq: 0`, the runtime replays the event +log from the start before tailing live events. + +### Cross-session observation + +```ruby +# Session A submits +handle = client_a.submit_job(agent: 'worker') + +# Session B observes +events = client_b.subscribe_job( + job_id: handle.job_id, + history: true, + from_event_seq: 0 +).take(3) +``` + +### History replay + +The runtime maintains an `EventLog` with a `resume_window_sec` retention. +Replay is sourced from this log; events evicted past the window are not +recoverable. Subscribe before that window elapses, or accept partial replay. + +### No cancel from a subscriber + +A subscriber handle observes but cannot cancel. Cancellation is reserved +for the session that owns the job — calling `cancel_job` on an +observer-side handle raises a permission error from the runtime. + +## Result streaming + +For results that don't fit comfortably in a single `job.result` payload, +emit `result_chunk` events and a terminal `job.result` carrying just the +`result_id` + `result_size`. + +### Producer side + +```ruby +HANDLER = lambda do |ctx| + ctx.stream_result(encoding: 'utf8') do |writer| + 30.times { |i| writer.write("chunk #{i}\n", more: i < 29) } + end + ctx.finish +end +``` + +`stream_result` allocates a `result_id`, then for each `writer.write` +emits an `Arcp::Job::EventKind::RESULT_CHUNK` event with monotonic +`chunk_seq`. The final chunk passes `more: false`. `ctx.finish` with no +`result:` argument terminates the job; the `job.result` envelope carries +the `result_id` and `result_size` so clients can verify completeness. + +Mixing `stream_result` with `ctx.finish(result: ...)` raises +`Arcp::Errors::ProtocolViolation`. + +### Encoding + +Pass `encoding: 'base64'` for binary payloads: + +```ruby +ctx.stream_result(encoding: 'base64') do |writer| + writer.write(File.binread('big.bin'), more: false) +end +``` + +The body's `decoded` helper handles either encoding on the consumer side. + +### Consumer side + +```ruby +handle = client.submit_job(agent: 'streamer') +chunks = handle.subscribe(client: client).select do |ev| + ev.kind == Arcp::Job::EventKind::RESULT_CHUNK +end +assembled = chunks.map { |ev| ev.body.decoded }.join + +result = handle.get_result(client: client) +result.result_id # matches chunks.first.body.result_id +result.result_size # total bytes written +``` + +### Backpressure + +Each `writer.write` blocks until the runtime accepts the chunk for +publication. The runtime fans out to subscribers via the event log; slow +subscribers do not block the producer. + +## See also + +- `guides/jobs.md` +- `guides/vendor-extensions.md` +- `guides/resume.md` diff --git a/docs/guides/jobs.md b/docs/guides/jobs.md new file mode 100644 index 0000000..ab5d28f --- /dev/null +++ b/docs/guides/jobs.md @@ -0,0 +1,140 @@ +--- +title: Jobs +sdk: ruby +kind: guide +order: 20 +spec_sections: [§7, §9.6, §12] +--- + +# Jobs + +A job is one invocation of one agent within an open session. It has a +deterministic lifecycle: `job.submit` → `job.accepted` → stream of +`job.event` → terminal `job.result` or `job.error`. + +## FSM + +``` +submitted + -> accepted (runtime allocated job_id and lease) + -> running (first event emitted by handler) + -> succeeded (job.result terminal) + -> failed (job.error terminal) + -> cancelled (job.error with code=CANCELLED) +``` + +A job is terminal on `succeeded`, `failed`, or `cancelled`. Subscriptions +end at the terminal envelope. The `event_seq` on each `job.event` is +monotonic per-job, starting at 1. + +## In Ruby + +```ruby +handle = client.submit_job( + agent: 'echo', + input: { 'msg' => 'hi' }, + idempotency_key: 'req-42', + max_runtime_sec: 60 +) +handle.job_id # String +handle.lease # Arcp::Lease::Lease (issued by runtime) +handle.submitted_at # ISO-8601 UTC + +handle.subscribe(client: client).each { |ev| puts ev.kind } +result = handle.get_result(client: client) +result.final_status # 'success' +result.result # whatever the handler passed to ctx.finish(result:) +``` + +## Idempotency + +Submitting twice with the same `idempotency_key` resolves to the same +`job_id`. A different payload under an existing key raises +`Arcp::Errors::DuplicateKey`. + +## Cancellation + +```ruby +handle.cancel(client: client, reason: 'user requested stop') +begin + handle.get_result(client: client) +rescue Arcp::Errors::Cancelled + # expected terminal state +end +``` + +## Listing jobs + +```ruby +client.list_jobs(status: 'succeeded', limit: 25).each do |summary| + puts summary.job_id +end +``` + +Returns a lazy `Enumerator` that walks `next_cursor` automatically. + +## Cost budgets + +The `cost.budget` capability caps spend per currency. Amounts are +`BigDecimal` end-to-end — no float drift on the wire or in the counter. + +### Request a budget at submit + +```ruby +handle = client.submit_job( + agent: 'shopper', + lease_request: Arcp::Lease::LeaseRequest.new( + capabilities: ['cost.spend'], + budget: Arcp::Lease::CostBudget.parse(['USD:1.00']), + expires_at: nil + ) +) +``` + +The wire form is a list of `currency:amount` strings (`['USD:1.00']`). +`CostBudget.parse` round-trips through `BigDecimal` and back via `#to_a`. + +### Spend from a handler + +```ruby +HANDLER = lambda do |ctx| + lm = $arcp_runtime.lease_manager + [BigDecimal('0.42'), BigDecimal('0.70')].each do |amount| + ctx.metric(name: 'cost.search', value: amount.to_s('F'), unit: 'USD') + lm.try_spend!(ctx.job_id, 'USD', amount) + end + ctx.finish(result: 'spent') +end +``` + +`try_spend!` atomically decrements the lease's `BudgetCounter`. If the +balance goes negative, the runtime emits `job.error` with code +`BUDGET_EXHAUSTED`. + +When spend is enforced by an upstream gateway instead of local counters, +configure provisioned credentials so `cost.budget` is baked into the +issued key — see `guides/credentials.md`. + +### Client-side exhaustion + +```ruby +begin + handle.get_result(client: client) +rescue Arcp::Errors::BudgetExhausted => e + e.details # { 'currency' => 'USD', 'requested' => ..., 'remaining' => ... } +end +``` + +### Inspect remaining balance + +```ruby +counter = $arcp_runtime.lease_manager.counter(job_id) +counter.remaining # { 'USD' => BigDecimal('0.30') } +counter.get('USD') # => BigDecimal('0.30') +``` + +## See also + +- `guides/job-events.md` +- `guides/leases.md` +- `guides/delegation.md` diff --git a/docs/concepts/leases.md b/docs/guides/leases.md similarity index 51% rename from docs/concepts/leases.md rename to docs/guides/leases.md index 8539146..08d4533 100644 --- a/docs/concepts/leases.md +++ b/docs/guides/leases.md @@ -1,19 +1,18 @@ --- title: Leases sdk: ruby -kind: concept -order: 12 +kind: guide +order: 22 spec_sections: [§9] --- # Leases -## What - A lease is the runtime's grant of authority to a job: a set of -capabilities, an optional expiry, and an optional per-currency budget. -The runtime issues one on `job.accepted` and attaches it to the job -context. Delegation requires the child lease to be a strict subset. +capabilities, an optional expiry, optional model patterns, and an +optional per-currency budget. The runtime issues one on `job.accepted` +and attaches it to the job context. Delegation requires the child lease +to be a strict subset. ## Capabilities @@ -21,7 +20,7 @@ Capabilities are opaque strings: `compute.read`, `net.http`, `cost.spend`, etc. The runtime decides what each means; the SDK only enforces subset relations at delegate time. -## Expires_at +## expires_at ```ruby handle = client.submit_job( @@ -45,7 +44,31 @@ budget.remaining('USD') # => BigDecimal('1.00') ``` `BudgetCounter#try_spend!` atomically decrements; overspend raises -`Arcp::Errors::BudgetExhausted`. +`Arcp::Errors::BudgetExhausted`. See `guides/jobs.md` for the full +spend workflow. + +## model.use + +```ruby +lease_request = Arcp::Lease::LeaseRequest.new( + capabilities: ['cost.spend'], + model_use: ['tier-fast/*'] +) +``` + +`model.use` is a set of glob patterns for model ids. Runtime code in +the path of an LLM call can enforce it with: + +```ruby +$arcp_runtime.lease_manager.check_model!( + ctx.job_id, + model_id: 'tier-fast/gpt-4o-mini' +) +``` + +A miss raises `Arcp::Errors::PermissionDenied`. Delegate subsetting +also checks `model.use`; a child may keep the same pattern or narrow a +parent glob to a literal model id. ## Subsetting on delegate @@ -53,16 +76,18 @@ budget.remaining('USD') # => BigDecimal('1.00') parent = $arcp_runtime.lease_manager.get(ctx.job_id) child_request = Arcp::Lease::LeaseRequest.new( capabilities: ['compute.read'], - budget: Arcp::Lease::CostBudget.parse(['USD:0.25']), - expires_at: nil + budget: Arcp::Lease::CostBudget.parse(['USD:0.25']), + model_use: ['tier-fast/gpt-4o-mini'], + expires_at: nil ) child = Arcp::Lease::Subsetting.bound(parent: parent, request: child_request) ``` -Excess capability, expires_at beyond parent, or per-currency budget +Excess capability, `expires_at` beyond parent, or per-currency budget above parent's remaining all raise `Arcp::Errors::LeaseSubsetViolation`. ## See also -- `concepts/delegation.md` -- `guides/budgets.md` +- `guides/delegation.md` +- `guides/jobs.md` +- `guides/credentials.md` diff --git a/docs/guides/quickstart.md b/docs/guides/quickstart.md deleted file mode 100644 index 15d55e2..0000000 --- a/docs/guides/quickstart.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: Quickstart -sdk: ruby -kind: guide -order: 0 -spec_sections: [§6, §7] ---- - -# Quickstart - -A complete standalone tutorial — server and client in one process, -backed by `MemoryTransport`. - -## Install - -```ruby -# Gemfile -gem 'arcp', '~> 1.0' -``` - -``` -bundle install -``` - -## The full script - -```ruby -require 'async' -require 'arcp' - -ECHO_HANDLER = lambda do |ctx| - ctx.log(level: 'info', message: "echoing #{ctx.input.inspect}") - ctx.progress(current: 1, total: 1, units: 'message') - ctx.finish(result: { 'echoed' => ctx.input }) -end - -Sync do - # 1. Build the runtime. - runtime = Arcp::Runtime::Runtime.new( - auth_verifier: Arcp::Auth::Bearer.from_token('demo', principal_id: 'alice'), - heartbeat_interval_sec: nil - ) - runtime.register_agent( - name: 'echo', versions: ['1.0.0'], default: '1.0.0', - handler: ECHO_HANDLER - ) - - # 2. Wire an in-process transport pair. - server_t, client_t = Arcp::Transport::MemoryTransport.pair - server = Async { runtime.accept(server_t) } - - # 3. Open a session. - client = Arcp::Client.open( - transport: client_t, - auth: { 'scheme' => 'bearer', 'token' => 'demo' }, - client_name: 'quickstart' - ) - - # 4. Submit, observe, collect. - handle = client.submit_job(agent: 'echo', input: { 'msg' => 'hi' }) - events = handle.subscribe(client: client).to_a - result = handle.get_result(client: client) - - puts "job_id: #{handle.job_id}" - puts "events: #{events.map(&:kind).inspect}" - puts "final_status: #{result.final_status}" - puts "result: #{result.result.inspect}" - - # 5. Tear down. - client.close - server.stop -end -``` - -## What you should see - -``` -job_id: job_... -events: ["log", "progress"] -final_status: success -result: {"echoed"=>{"msg"=>"hi"}} -``` - -## Next steps - -- `guides/deployment.md` — running under falcon over WebSocket. -- `guides/agent-versioning.md` — `name@version` agent refs. -- `guides/result-streaming.md` — `result_chunk` for large outputs. -- `guides/budgets.md` — `cost.budget` accounting. diff --git a/docs/guides/result-streaming.md b/docs/guides/result-streaming.md deleted file mode 100644 index e43ccc1..0000000 --- a/docs/guides/result-streaming.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Result streaming -sdk: ruby -kind: guide -order: 22 -spec_sections: [§8.4] ---- - -# Result streaming - -For results that don't fit comfortably in a single `job.result` payload, -emit `result_chunk` events and a terminal `job.result` carrying just the -`result_id` + `result_size`. - -## Producer side - -```ruby -HANDLER = lambda do |ctx| - ctx.stream_result(encoding: 'utf8') do |writer| - 30.times { |i| writer.write("chunk #{i}\n", more: i < 29) } - end - ctx.finish -end -``` - -`stream_result` allocates a `result_id`, then for each `writer.write` -emits an `Arcp::Job::EventKind::RESULT_CHUNK` event with monotonic -`chunk_seq`. The final chunk passes `more: false`. `ctx.finish` with no -`result:` argument terminates the job; the `job.result` envelope carries -the `result_id` and `result_size` so clients can verify completeness. - -Mixing `stream_result` with `ctx.finish(result: ...)` raises -`Arcp::Errors::ProtocolViolation`. - -## Encoding - -Pass `encoding: 'base64'` for binary payloads: - -```ruby -ctx.stream_result(encoding: 'base64') do |writer| - writer.write(File.binread('big.bin'), more: false) -end -``` - -The body's `decoded` helper handles either encoding on the consumer -side. - -## Consumer side - -```ruby -handle = client.submit_job(agent: 'streamer') -chunks = handle.subscribe(client: client).select do |ev| - ev.kind == Arcp::Job::EventKind::RESULT_CHUNK -end -assembled = chunks.map { |ev| ev.body.decoded }.join - -result = handle.get_result(client: client) -result.result_id # matches chunks.first.body.result_id -result.result_size # total bytes written -``` - -## Backpressure - -Each `writer.write` blocks until the runtime accepts the chunk for -publication. The runtime fans out to subscribers via the event log; slow -subscribers do not block the producer. diff --git a/docs/guides/resume.md b/docs/guides/resume.md new file mode 100644 index 0000000..9e3c040 --- /dev/null +++ b/docs/guides/resume.md @@ -0,0 +1,74 @@ +--- +title: Resume +sdk: ruby +kind: guide +order: 12 +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`. + +## Resume token + +```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 + +```ruby +new_client = Arcp::Client.open( + transport: new_transport, + auth: auth, + resume: { + 'token' => token, + 'last_event_seq' => { job_id => last_seq } + } +) +``` + +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`. + +## Heartbeats and reconnect + +`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. + +```ruby +runtime = Arcp::Runtime::Runtime.new( + auth_verifier: verifier, + heartbeat_interval_sec: 30 # nil to disable +) +client = Arcp::Client.open(transport: t, auth: auth) +client.session.heartbeat_interval_sec # => 30 +``` + +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`. + +## See also + +- `guides/sessions.md` +- `guides/jobs.md` diff --git a/docs/guides/sessions.md b/docs/guides/sessions.md new file mode 100644 index 0000000..1190e3b --- /dev/null +++ b/docs/guides/sessions.md @@ -0,0 +1,141 @@ +--- +title: Sessions +sdk: ruby +kind: guide +order: 10 +spec_sections: [§6.1, §6.2] +--- + +# Sessions + +A session is the unit of authenticated, capability-negotiated connection +between one client and one runtime. The client sends `session.hello`, +the runtime responds with `session.welcome`, and from that point all +subsequent envelopes carry the same `session_id`. + +## Wire shape + +```json +// session.hello +{ "type": "session.hello", + "payload": { + "client_name": "my-app", + "client_version": "1.2.3", + "auth": { "scheme": "bearer", "token": "..." }, + "capabilities": { "features": ["heartbeat","ack","list_jobs"], "encodings": ["utf8","base64"] } + } +} + +// session.welcome +{ "type": "session.welcome", + "payload": { + "runtime_version": "1.0.0", + "capabilities": { "features": [...], "encodings": [...], "agents": [...] }, + "heartbeat_interval_sec": 30, + "resume_token": "...", + "resume_window_sec": 300 + } +} +``` + +## In Ruby + +```ruby +caps = Arcp::Session::CapabilitySet.local( + features: [Arcp::Session::Feature::HEARTBEAT, + Arcp::Session::Feature::LIST_JOBS, + Arcp::Session::Feature::SUBSCRIBE] +) +client = Arcp::Client.open( + transport: transport, + auth: { 'scheme' => 'bearer', 'token' => 'demo' }, + capabilities: caps +) +client.session.supports?(Arcp::Session::Feature::LIST_JOBS) # => true +client.session.capabilities.agents # => Arcp::Session::AgentInventory +``` + +The post-welcome snapshot is an `Arcp::Session::Info` value: `id`, +`runtime_version`, `capabilities` (intersected), `agents` (the runtime's +inventory), `heartbeat_interval_sec`, `resume_token`, `resume_window_sec`. + +## Lifecycle + +- `opening` — `session.hello` sent, awaiting reply +- `open` — `session.welcome` received, normal traffic +- `closing` — local `close()` called, `session.bye` sent +- `closed` — transport closed, all queues drained + +`session.error` at any stage maps to one of `Arcp::Errors::*` and is +raised from `Client.open` or the current call. + +## Capability negotiation + +A session's `CapabilitySet` advertises `features`, `encodings`, and +(server-side) an `AgentInventory`. The handshake intersects client and +server sets; the result is stored on `client.session.capabilities`. + +### Feature constants + +```ruby +Arcp::Session::Feature::HEARTBEAT # 'heartbeat' +Arcp::Session::Feature::ACK # 'ack' +Arcp::Session::Feature::LIST_JOBS # 'list_jobs' +Arcp::Session::Feature::SUBSCRIBE # 'subscribe' +Arcp::Session::Feature::LEASE_EXPIRES_AT # 'lease_expires_at' +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::ALL` is a frozen Array of all nine. + +### CapabilitySet + +```ruby +caps = Arcp::Session::CapabilitySet.local( + features: [Arcp::Session::Feature::HEARTBEAT, Arcp::Session::Feature::LIST_JOBS], + encodings: %w[utf8 base64] +) + +caps.supports?(Arcp::Session::Feature::LIST_JOBS) # => true +caps.to_h +# => { 'features' => ['heartbeat', 'list_jobs'], 'encodings' => ['utf8', 'base64'] } +``` + +### Negotiation example + +```ruby +client_caps = Arcp::Session::CapabilitySet.local( + features: ['heartbeat', 'list_jobs'], + encodings: ['utf8'] +) +server_caps = Arcp::Session::CapabilitySet.local( + features: ['heartbeat', 'subscribe'], + encodings: ['utf8', 'base64'] +) + +effective = client_caps.intersect(server_caps) +effective.features # ['heartbeat'] -- intersection +effective.encodings # ['utf8'] -- intersection +``` + +`Arcp::Client.open` performs this intersection automatically and stores +the result on `client.session.capabilities`. + +### Checking a feature + +```ruby +if client.session.supports?(Arcp::Session::Feature::SUBSCRIBE) + client.subscribe_job(job_id: id, history: true, from_event_seq: 0) +end +``` + +Calling a feature method without the negotiated capability raises +`Arcp::Errors::UnnegotiatedFeature`. + +## See also + +- `guides/auth.md` +- `guides/resume.md` diff --git a/docs/concepts/vendor-extensions.md b/docs/guides/vendor-extensions.md similarity index 89% rename from docs/concepts/vendor-extensions.md rename to docs/guides/vendor-extensions.md index 5a3fb68..fe728b0 100644 --- a/docs/concepts/vendor-extensions.md +++ b/docs/guides/vendor-extensions.md @@ -1,15 +1,13 @@ --- title: Vendor extensions sdk: ruby -kind: concept -order: 19 +kind: guide +order: 50 spec_sections: [§5.1, §8.2, §15] --- # Vendor extensions -## What - Event kinds prefixed `x-vendor.` carry implementation-specific data. The runtime forwards them verbatim; clients that don't recognize the prefix ignore them. @@ -38,8 +36,8 @@ body — no `EventBody` class is allocated. handle.subscribe(client: client).each do |event| if event.kind.start_with?('x-vendor.acme.') stage = event.body['stage'] - pct = event.body['percent'] - # ... + pct = event.body['percent'] + puts "#{stage}: #{pct}%" end end ``` @@ -48,4 +46,4 @@ end ## See also -- `concepts/events.md` +- `guides/job-events.md` diff --git a/docs/recipes.md b/docs/recipes.md index b6fbb0d..de981b2 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -91,3 +91,190 @@ client.submit_job(agent: 'code-refactor@1.0.0', input: { ... }) ``` Omit `@version` to use the registered default. + +## Lease-scoped vendor credentials (email-vendor-leases) + +Provision a short-lived upstream key that is automatically revoked when the +job ends. The runtime copies `cost.budget`, `model.use`, and `expires_at` +into the credential's constraints so the upstream gateway can enforce the +same bounds. + +```ruby +# 1. Configure a provisioner at runtime build time +provisioner = Arcp::Credentials::InMemoryProvisioner.new( + endpoint: 'https://mail-gateway.example/v1', + profile: 'sendgrid' +) + +runtime = Arcp::Runtime::Runtime.new( + auth_verifier: auth, + credential_provisioner: provisioner, + credential_store: Arcp::Credentials::InMemoryStore.new +) + +# 2. Submit a job requesting the spend capability +handle = client.submit_job( + agent: 'email-sender', + lease_request: Arcp::Lease::LeaseRequest.new( + capabilities: ['cost.spend'], + budget: Arcp::Lease::CostBudget.parse(['USD:0.10']), + expires_at: (Time.now.utc + 300).strftime('%FT%TZ') + ) +) + +# 3. Retrieve the provisioned credential from the accepted handle +credential = handle.credential_for( + endpoint: 'https://mail-gateway.example/v1' +) + +# credential.value holds the short-lived key; never log it directly +# Use credential.to_redacted_h for logs/metrics +puts credential.to_redacted_h.inspect + +handle.get_result(client: client) +# Credential is revoked automatically on job termination +``` + +## MCP-style skill wrapper (mcp-skill) + +Wrap a single-operation agent so it looks like an MCP tool call: one input +hash in, one structured result out, all errors mapped to ARCP error codes. + +```ruby +module Skills + module SummarizeText + # @param text [String] + # @return [Hash] + def self.call(client:, text:) + handle = client.submit_job( + agent: 'summarizer@1', + input: { 'text' => text } + ) + + result = handle.get_result(client: client) + result.output + rescue Arcp::Errors::LeaseExpired, Arcp::Errors::BudgetExhausted => e + { 'error' => e.code, 'message' => e.message } + rescue Arcp::Errors::Error => e + { 'error' => e.code, 'message' => e.message, 'retryable' => e.retryable? } + end + end +end + +# Caller +Sync do + Arcp::Client.connect(transport: transport) do |client| + puts Skills::SummarizeText.call( + client: client, + text: 'Long article text here...' + ).inspect + end +end +``` + +## Multi-agent budget (multi-agent-budget) + +Delegate a child job with a sub-budget carved from the parent lease. The +parent's `cost.budget` decrements as the child spends; `LeaseSubsetViolation` +is raised if the requested child budget exceeds the parent's remaining amount. + +```ruby +# Server-side handler for the orchestrator agent +orchestrator = Arcp::Runtime::Handler.new('orchestrator') do |ctx| + parent_lease = ctx.lease + + # Carve a sub-budget for the child job + child_request = Arcp::Lease::LeaseRequest.new( + capabilities: parent_lease.capabilities & ['cost.spend'], + budget: Arcp::Lease::CostBudget.parse(['USD:0.25']), + expires_at: parent_lease.expires_at + ) + child_lease = Arcp::Lease::Subsetting.bound( + parent: parent_lease, + request: child_request + ) + + # Emit a delegate event so the runtime issues a child job + ctx.emit(:delegate, + agent: 'sub-worker', + lease: child_lease, + input: { 'task' => 'unit-of-work' }, + delegate_id: SecureRandom.hex(8)) + + # Wait for all delegate events to settle, then finish + ctx.finish(output: { 'status' => 'done' }) +end + +# Client-side — request the parent budget +handle = client.submit_job( + agent: 'orchestrator', + lease_request: Arcp::Lease::LeaseRequest.new( + capabilities: ['cost.spend'], + budget: Arcp::Lease::CostBudget.parse(['USD:1.00']) + ) +) + +handle.subscribe(client: client).each do |event| + if event.kind == Arcp::Job::EventKind::DELEGATE + puts "Delegated to #{event.body.agent} " \ + "with budget #{event.body.lease.budget.inspect}" + end +end + +result = handle.get_result(client: client) +puts result.output.inspect +``` + +## Stream + resume (stream-resume) + +Assemble a chunked result across a simulated transport drop. The client +reconnects with the saved `resume_token` and `last_event_seq`, and the +runtime replays missed chunks from its event log. + +```ruby +resume_token = nil +last_seq = 0 +assembled = [] + +# First connection — collect some chunks then simulate a drop +Arcp::Client.connect(transport: first_transport) do |client| + handle = client.submit_job(agent: 'big-streamer') + + handle.subscribe(client: client).each do |event| + case event.kind + when Arcp::Job::EventKind::RESULT_CHUNK + assembled << event.body.decoded + last_seq = event.seq + + when Arcp::Job::EventKind::STATUS + if event.body.phase == 'connected' + # Stash the resume token from the welcome payload + resume_token = client.session.resume_token + break # simulate drop after first few chunks + end + end + end +end + +# Second connection — resume from where we left off +second_transport = build_transport( + resume_token: resume_token, + last_event_seq: last_seq +) + +Arcp::Client.connect(transport: second_transport) do |client| + handle = client.reattach_job(handle.job_id) + + handle.subscribe(client: client).each do |event| + next unless event.kind == Arcp::Job::EventKind::RESULT_CHUNK + + assembled << event.body.decoded + break unless event.body.more + end + + handle.get_result(client: client) +end + +full_text = assembled.join +puts "Assembled #{full_text.bytesize} bytes across reconnect" +``` diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md deleted file mode 100644 index 53b2332..0000000 --- a/docs/reference/capabilities.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Capabilities -sdk: ruby -kind: reference -order: 51 -spec_sections: [§6.2] ---- - -# Capabilities - -A session's `CapabilitySet` advertises `features`, `encodings`, and -(server-side) an `AgentInventory`. The handshake intersects client and -server sets; the result is `session.capabilities`. - -## Feature constants - -```ruby -Arcp::Session::Feature::HEARTBEAT # 'heartbeat' -Arcp::Session::Feature::ACK # 'ack' -Arcp::Session::Feature::LIST_JOBS # 'list_jobs' -Arcp::Session::Feature::SUBSCRIBE # 'subscribe' -Arcp::Session::Feature::LEASE_EXPIRES_AT # 'lease_expires_at' -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::ALL` is a frozen Array of all nine. - -## CapabilitySet - -```ruby -caps = Arcp::Session::CapabilitySet.local( - features: [Arcp::Session::Feature::HEARTBEAT, Arcp::Session::Feature::LIST_JOBS], - encodings: %w[utf8 base64] -) - -caps.supports?(Arcp::Session::Feature::LIST_JOBS) # => true -caps.to_h -# => { 'features' => ['heartbeat', 'list_jobs'], 'encodings' => ['utf8', 'base64'] } -``` - -## Negotiation - -```ruby -client_caps = Arcp::Session::CapabilitySet.local(features: ['heartbeat', 'list_jobs'], encodings: ['utf8']) -server_caps = Arcp::Session::CapabilitySet.local(features: ['heartbeat', 'subscribe'], encodings: ['utf8', 'base64']) - -effective = client_caps.intersect(server_caps) -effective.features # ['heartbeat'] -- intersection -effective.encodings # ['utf8'] -- intersection -``` - -The `Arcp::Client.open` flow performs this intersection automatically -and stores the result on `client.session.capabilities`. - -## Checking a feature - -```ruby -if client.session.supports?(Arcp::Session::Feature::SUBSCRIBE) - client.subscribe_job(job_id: id, history: true, from_event_seq: 0) -end -``` - -Calling a feature method without the negotiated capability raises -`Arcp::Errors::UnnegotiatedFeature`. diff --git a/docs/reference/conformance.md b/docs/reference/conformance.md deleted file mode 100644 index a88fd99..0000000 --- a/docs/reference/conformance.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Conformance -sdk: ruby -kind: reference -order: 52 ---- - -# Conformance - -The full spec-to-code matrix lives at the repo root: -[../../CONFORMANCE.md](../../CONFORMANCE.md). - -Every MUST/SHOULD in `../spec/docs/draft-arcp-1.1.md` §4–§16 is -implemented and cites the file under `lib/arcp/` that satisfies it. diff --git a/lib/arcp.rb b/lib/arcp.rb index 3726da0..b8d7954 100644 --- a/lib/arcp.rb +++ b/lib/arcp.rb @@ -9,6 +9,8 @@ require_relative 'arcp/envelope' require_relative 'arcp/trace' require_relative 'arcp/lease' +require_relative 'arcp/credential' +require_relative 'arcp/credential_provisioner' require_relative 'arcp/session' require_relative 'arcp/job' require_relative 'arcp/auth' diff --git a/lib/arcp/client.rb b/lib/arcp/client.rb index 4b56ccc..e132633 100644 --- a/lib/arcp/client.rb +++ b/lib/arcp/client.rb @@ -140,7 +140,9 @@ def submit_job(agent:, input: nil, lease_request: nil, lease_constraints: nil, accepted = Arcp::Job::Accepted.from_h(accepted_env.payload) Arcp::Job::Handle.new( job_id: accepted.job_id, agent: accepted.agent, - submitted_at: accepted.accepted_at, lease: accepted.lease + submitted_at: accepted.accepted_at, + lease: accepted.lease, + credentials: accepted.credentials ) end diff --git a/lib/arcp/credential.rb b/lib/arcp/credential.rb new file mode 100644 index 0000000..c75d831 --- /dev/null +++ b/lib/arcp/credential.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Arcp + Credential = Data.define(:id, :scheme, :value, :endpoint, :profile, :constraints) do + def initialize(id:, scheme:, value:, endpoint:, profile: nil, constraints: nil) + super( + id: id, + scheme: scheme, + value: value, + endpoint: endpoint, + profile: profile, + constraints: constraints || {} + ) + end + + def self.from_h(h) + h = h.transform_keys(&:to_s) + new( + id: h.fetch('id'), + scheme: h.fetch('scheme'), + value: h.fetch('value'), + endpoint: h.fetch('endpoint'), + profile: h['profile'], + constraints: h['constraints'] || {} + ) + end + + def to_h + out = { 'id' => id, 'scheme' => scheme, 'value' => value, 'endpoint' => endpoint } + out['profile'] = profile if profile + out['constraints'] = constraints if constraints && !constraints.empty? + out + end + + def to_redacted_h + to_h.merge('value' => '[REDACTED]') + end + end + + Credential.const_set(:SCHEME_BEARER, 'bearer') unless Credential.const_defined?(:SCHEME_BEARER) + + module ModelPattern + FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB + + module_function + + def match?(patterns, model_id) + Array(patterns).any? { |pattern| File.fnmatch?(pattern, model_id, FLAGS) } + end + + def implied_by?(parent_patterns, child_pattern) + Array(parent_patterns).any? do |parent| + child_pattern == parent || literal_match?(parent, child_pattern) + end + end + + def literal_match?(parent_pattern, child_pattern) + !glob?(child_pattern) && match?([parent_pattern], child_pattern) + end + + def glob?(pattern) + pattern.match?(/[*?\[\]{}]/) + end + end +end diff --git a/lib/arcp/credential_provisioner.rb b/lib/arcp/credential_provisioner.rb new file mode 100644 index 0000000..157346f --- /dev/null +++ b/lib/arcp/credential_provisioner.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require_relative 'credential' +require_relative 'errors' + +module Arcp + module CredentialProvisioner + def issue(lease:, job_id:, agent:, principal_id:) + raise NotImplementedError + end + + def revoke(credential_id:) + raise NotImplementedError + end + end + + module Credentials + BUDGET_EXHAUSTED_CODES = %w[BUDGET_EXHAUSTED budget_exhausted insufficient_quota].freeze + + def self.translate_upstream_error(error) + return error unless budget_exhausted?(error) + + Arcp::Errors::BudgetExhausted.new( + error.message, + details: { 'upstream_class' => error.class.name } + ) + end + + def self.budget_exhausted?(error) + code = error.respond_to?(:code) ? error.code.to_s : nil + status = error.respond_to?(:status) ? error.status.to_i : nil + BUDGET_EXHAUSTED_CODES.include?(code) || status == 402 + end + + class InMemoryProvisioner + include Arcp::CredentialProvisioner + + attr_reader :issued, :revoked + + def initialize(endpoint: 'https://gateway.test/v1', profile: 'openai') + @endpoint = endpoint + @profile = profile + @issued = [] + @revoked = [] + end + + def issue(lease:, job_id:, agent:, principal_id:) + credential = Arcp::Credential.new( + id: "cred_#{job_id}_0", + scheme: Arcp::Credential::SCHEME_BEARER, + value: "sk-test-#{job_id}", + endpoint: @endpoint, + profile: @profile, + constraints: constraints_for(lease) + ) + @issued << { + credential: credential, + job_id: job_id, + agent: agent, + principal_id: principal_id + } + [credential] + end + + def revoke(credential_id:) + @revoked << credential_id + nil + end + + private + + def constraints_for(lease) + return {} unless lease + + { + 'cost.budget' => lease.budget&.to_a, + 'model.use' => lease.model_use, + 'expires_at' => lease.expires_at + }.compact + end + end + + class CredentialStore + def record(job_id:, credential_id:) + raise NotImplementedError + end + + def forget(job_id:, credential_id:) + raise NotImplementedError + end + + def outstanding(job_id:) + raise NotImplementedError + end + + def all_outstanding + raise NotImplementedError + end + end + + class InMemoryStore < CredentialStore + def initialize + super + @by_job = Hash.new { |hash, key| hash[key] = [] } + @mutex = Mutex.new + end + + def record(job_id:, credential_id:) + @mutex.synchronize { @by_job[job_id] |= [credential_id] } + nil + end + + def forget(job_id:, credential_id:) + @mutex.synchronize do + @by_job[job_id].delete(credential_id) + @by_job.delete(job_id) if @by_job[job_id].empty? + end + nil + end + + def outstanding(job_id:) + @mutex.synchronize { @by_job[job_id].dup.freeze } + end + + def all_outstanding + @mutex.synchronize do + @by_job.transform_values { |ids| ids.dup.freeze }.freeze + end + end + end + end +end diff --git a/lib/arcp/job/accepted.rb b/lib/arcp/job/accepted.rb index 723297d..ae8b71e 100644 --- a/lib/arcp/job/accepted.rb +++ b/lib/arcp/job/accepted.rb @@ -1,23 +1,37 @@ # frozen_string_literal: true +require_relative '../credential' + module Arcp module Job - Accepted = Data.define(:job_id, :agent, :accepted_at, :lease) do + Accepted = Data.define(:job_id, :agent, :accepted_at, :lease, :credentials) do + def initialize(job_id:, agent:, accepted_at:, lease: nil, credentials: nil) + super + end + def self.from_h(h) h = h.transform_keys(&:to_s) new( job_id: h.fetch('job_id'), agent: h.fetch('agent'), accepted_at: h['accepted_at'], - lease: h['lease'] ? Arcp::Lease::Lease.from_h(h['lease']) : nil + lease: h['lease'] ? Arcp::Lease::Lease.from_h(h['lease']) : nil, + credentials: credentials_from(h) ) end def to_h out = { 'job_id' => job_id, 'agent' => agent, 'accepted_at' => accepted_at } out['lease'] = lease.to_h if lease + out['credentials'] = credentials.map(&:to_h) if credentials && !credentials.empty? out end + + def self.credentials_from(h) + return nil unless h['credentials'] + + Array(h['credentials']).map { |credential| Arcp::Credential.from_h(credential) }.freeze + end end end end diff --git a/lib/arcp/job/event_body/status.rb b/lib/arcp/job/event_body/status.rb index 138805f..0023c7d 100644 --- a/lib/arcp/job/event_body/status.rb +++ b/lib/arcp/job/event_body/status.rb @@ -3,15 +3,20 @@ module Arcp module Job module EventBody - Status = Data.define(:phase, :message) do + Status = Data.define(:phase, :message, :fields) do + def initialize(phase:, message: nil, fields: {}) + super(phase: phase, message: message, fields: fields || {}) + end + def self.from_h(h) h = h.transform_keys(&:to_s) - new(phase: h.fetch('phase'), message: h['message']) + new(phase: h.fetch('phase'), message: h['message'], fields: h['fields'] || {}) end def to_h out = { 'phase' => phase } out['message'] = message if message + out['fields'] = fields unless fields.empty? out end end diff --git a/lib/arcp/job/handle.rb b/lib/arcp/job/handle.rb index b8b0725..6b121d1 100644 --- a/lib/arcp/job/handle.rb +++ b/lib/arcp/job/handle.rb @@ -2,10 +2,15 @@ module Arcp module Job - Handle = Data.define(:job_id, :agent, :submitted_at, :lease) do + Handle = Data.define(:job_id, :agent, :submitted_at, :lease, :credentials) do + def initialize(job_id:, agent:, submitted_at:, lease: nil, credentials: nil) + super + end + def subscribe(client:, **kw) = client.subscribe_job(job_id: job_id, **kw) def cancel(client:, reason: nil) = client.cancel_job(job_id: job_id, reason: reason) def get_result(client:) = client.get_result(job_id: job_id) + def credential_for(endpoint:) = Array(credentials).find { |credential| credential.endpoint == endpoint } end end end diff --git a/lib/arcp/lease.rb b/lib/arcp/lease.rb index 7950b27..9f3c991 100644 --- a/lib/arcp/lease.rb +++ b/lib/arcp/lease.rb @@ -4,6 +4,7 @@ require 'time' require_relative 'errors' +require_relative 'credential' module Arcp module Lease @@ -76,7 +77,16 @@ def snapshot end end - LeaseRequest = Data.define(:capabilities, :budget, :expires_at) do + LeaseRequest = Data.define(:capabilities, :budget, :model_use, :expires_at) do + def initialize(capabilities:, budget: nil, model_use: nil, expires_at: nil) + super( + capabilities: Array(capabilities).freeze, + budget: budget, + model_use: model_use ? Array(model_use).freeze : nil, + expires_at: expires_at + ) + end + def self.from_h(h) return nil if h.nil? @@ -84,6 +94,7 @@ def self.from_h(h) new( capabilities: Array(h['capabilities']).freeze, budget: h['cost.budget'] ? CostBudget.parse(h['cost.budget']) : nil, + model_use: h['model.use'] ? Array(h['model.use']).freeze : nil, expires_at: h['expires_at'] ) end @@ -91,18 +102,31 @@ def self.from_h(h) def to_h out = { 'capabilities' => capabilities } out['cost.budget'] = budget.to_a if budget - out['expires_at'] = expires_at if expires_at + out['model.use'] = model_use if model_use + out['expires_at'] = expires_at if expires_at out end end - Lease = Data.define(:id, :capabilities, :budget, :expires_at, :issued_at) do + Lease = Data.define(:id, :capabilities, :budget, :model_use, :expires_at, :issued_at) do + def initialize(id:, capabilities:, issued_at:, budget: nil, model_use: nil, expires_at: nil) + super( + id: id, + capabilities: Array(capabilities).freeze, + budget: budget, + model_use: model_use ? Array(model_use).freeze : nil, + expires_at: expires_at, + issued_at: issued_at + ) + end + def self.from_h(h) h = h.transform_keys(&:to_s) new( id: h.fetch('id'), capabilities: Array(h['capabilities']).freeze, budget: h['cost.budget'] ? CostBudget.parse(h['cost.budget']) : nil, + model_use: h['model.use'] ? Array(h['model.use']).freeze : nil, expires_at: h['expires_at'], issued_at: h['issued_at'] ) @@ -111,7 +135,8 @@ def self.from_h(h) def to_h out = { 'id' => id, 'capabilities' => capabilities, 'issued_at' => issued_at } out['cost.budget'] = budget.to_a if budget - out['expires_at'] = expires_at if expires_at + out['model.use'] = model_use if model_use + out['expires_at'] = expires_at if expires_at out end @@ -160,14 +185,28 @@ def bound(parent:, request:, parent_remaining: nil) budget = request.budget end + model_use = bound_model_use(parent: parent, request: request) + Lease.new( id: Arcp::Ids.session_id.sub(/^ses_/, 'lse_'), capabilities: request.capabilities, budget: budget, + model_use: model_use, expires_at: request.expires_at || parent.expires_at, issued_at: Time.now.utc.iso8601 ) end + + def bound_model_use(parent:, request:) + return nil unless request.model_use + + unless request.model_use.all? { |pattern| Arcp::ModelPattern.implied_by?(parent.model_use, pattern) } + raise Arcp::Errors::LeaseSubsetViolation, + "child model.use expands beyond parent: #{request.model_use.inspect}" + end + + request.model_use + end end end end diff --git a/lib/arcp/runtime.rb b/lib/arcp/runtime.rb index 750547d..9731b19 100644 --- a/lib/arcp/runtime.rb +++ b/lib/arcp/runtime.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative 'runtime/runtime' +require_relative 'runtime/credential_registry' require_relative 'runtime/job_manager' require_relative 'runtime/lease_manager' require_relative 'runtime/subscription_manager' diff --git a/lib/arcp/runtime/credential_registry.rb b/lib/arcp/runtime/credential_registry.rb new file mode 100644 index 0000000..7f38ca7 --- /dev/null +++ b/lib/arcp/runtime/credential_registry.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require_relative '../clock' +require_relative '../credential' +require_relative '../credential_provisioner' + +module Arcp + module Runtime + class CredentialRegistry + def initialize(provisioner:, store:, clock: Arcp::SystemClock.new) + @provisioner = provisioner + @store = store + @clock = clock + @mutex = Mutex.new + end + + def issue_for(job_id:, lease:, agent:, principal_id:) + credentials = @provisioner.issue( + lease: lease, job_id: job_id, agent: agent, principal_id: principal_id + ) + Array(credentials).each do |credential| + @store.record(job_id: job_id, credential_id: credential.id) + end + Array(credentials).freeze + end + + def rotate(job_id:, credential_id:, new_value:) + revoke(credential_id) + new_id = "#{credential_id}_rotated_#{@clock.now.to_i}" + @store.record(job_id: job_id, credential_id: new_id) + new_id + end + + def revoke_all(job_id:) + @store.outstanding(job_id: job_id).count do |credential_id| + revoke(credential_id).tap do |revoked| + @store.forget(job_id: job_id, credential_id: credential_id) if revoked + end + end + end + + def reconcile_on_startup! + @store.all_outstanding.each do |job_id, credential_ids| + credential_ids.each do |credential_id| + @store.forget(job_id: job_id, credential_id: credential_id) if revoke(credential_id) + end + end + nil + end + + private + + def revoke(credential_id) + attempts = 0 + begin + attempts += 1 + @provisioner.revoke(credential_id: credential_id) + true + rescue StandardError + retry if attempts < 2 + + false + end + end + end + end +end diff --git a/lib/arcp/runtime/job_context.rb b/lib/arcp/runtime/job_context.rb index eeb49c9..857e991 100644 --- a/lib/arcp/runtime/job_context.rb +++ b/lib/arcp/runtime/job_context.rb @@ -48,9 +48,22 @@ def metric(name:, value:, unit: nil) body: Arcp::Job::EventBody::Metric.new(name: name, value: value, unit: unit)) end - def status(phase:, message: nil) + def status(phase:, message: nil, fields: {}) emit(kind: Arcp::Job::EventKind::STATUS, - body: Arcp::Job::EventBody::Status.new(phase: phase, message: message)) + body: Arcp::Job::EventBody::Status.new(phase: phase, message: message, + fields: fields)) + end + + def rotate_credential(id:, new_value:) + new_id = @sink.runtime.credential_registry&.rotate( + job_id: job_id, + credential_id: id, + new_value: new_value + ) + status( + phase: 'credential_rotated', + fields: { 'id' => new_id || id, 'value' => new_value } + ) end def tool_call(call_id:, tool:, args:) diff --git a/lib/arcp/runtime/job_manager.rb b/lib/arcp/runtime/job_manager.rb index 013b564..97cc0b3 100644 --- a/lib/arcp/runtime/job_manager.rb +++ b/lib/arcp/runtime/job_manager.rb @@ -16,6 +16,8 @@ def with(**kw) = self.class.new(**to_h, **kw) # Owns agent registry + per-job lifecycle. Submitted jobs run as # child `Async::Task`s; cancellation propagates via `task.stop`. class JobManager + attr_reader :runtime + def initialize(runtime:, lease_manager:, subscription_manager:, event_log:, clock: Arcp::SystemClock.new) @runtime = runtime @leases = lease_manager @@ -85,6 +87,9 @@ def submit(submit:, principal_id:, session_id:, session_actor:) lease = build_lease(submit, job_id) @leases.register(job_id, lease) if lease + credentials = issue_credentials( + job_id: job_id, lease: lease, agent: resolved, principal_id: principal_id + ) record = JobRecord.new( job_id: job_id, agent: resolved, principal_id: principal_id, @@ -103,7 +108,7 @@ def submit(submit:, principal_id:, session_id:, session_actor:) end @mutex.synchronize { @jobs[job_id] = @jobs[job_id].with(task: task, status: 'running') } - [job_id, resolved, lease] + [job_id, resolved, lease, credentials] end def cancel(job_id:, principal_id:, reason: nil) @@ -179,6 +184,7 @@ def publish_result(job_id, result) @event_log.append(env.session_id, env) @subs.fanout(job_id, env) @subs.clear(job_id) + @runtime.credential_registry&.revoke_all(job_id: job_id) @leases.revoke(job_id) end @@ -195,11 +201,20 @@ def publish_error(job_id, error) @event_log.append(env.session_id, env) @subs.fanout(job_id, env) @subs.clear(job_id) + @runtime.credential_registry&.revoke_all(job_id: job_id) @leases.revoke(job_id) end private + def issue_credentials(job_id:, lease:, agent:, principal_id:) + return nil unless @runtime.credential_registry + + @runtime.credential_registry.issue_for( + job_id: job_id, lease: lease, agent: agent, principal_id: principal_id + ) + end + def build_lease(submit, job_id) return nil unless submit.lease_request @@ -207,6 +222,7 @@ def build_lease(submit, job_id) id: "lse_#{job_id}", capabilities: submit.lease_request.capabilities, budget: submit.lease_request.budget, + model_use: submit.lease_request.model_use, expires_at: submit.lease_constraints&.expires_at || submit.lease_request.expires_at, issued_at: @clock.now.iso8601 ) diff --git a/lib/arcp/runtime/lease_manager.rb b/lib/arcp/runtime/lease_manager.rb index f2c4ac9..8f86655 100644 --- a/lib/arcp/runtime/lease_manager.rb +++ b/lib/arcp/runtime/lease_manager.rb @@ -5,8 +5,9 @@ module Runtime # Tracks per-job leases and bound budget counters. The runtime asks # `#check!(job_id, capability:)` before every authority op. class LeaseManager - def initialize(clock: Arcp::SystemClock.new) + def initialize(clock: Arcp::SystemClock.new, enforce_model_use: false) @clock = clock + @enforce_model_use = enforce_model_use @leases = {} @counters = {} @mutex = Mutex.new @@ -42,6 +43,18 @@ def check!(job_id, capability:) ) end + def check_model!(job_id, model_id:) + lease = get(job_id) + return true if lease.nil? && !@enforce_model_use + + return true if lease&.model_use && Arcp::ModelPattern.match?(lease.model_use, model_id) + + raise Arcp::Errors::PermissionDenied.new( + "model #{model_id.inspect} not permitted by lease", + details: { 'model' => model_id, 'lease_id' => lease&.id } + ) + end + # Try to decrement the bound budget. Returns true on success, raises # BudgetExhausted if no balance covers the amount. Straight-line — # no scheduler-yielding calls between read and write. diff --git a/lib/arcp/runtime/runtime.rb b/lib/arcp/runtime/runtime.rb index a9cb62b..247adf5 100644 --- a/lib/arcp/runtime/runtime.rb +++ b/lib/arcp/runtime/runtime.rb @@ -6,9 +6,11 @@ require_relative '../session' require_relative '../job' require_relative '../lease' +require_relative '../credential_provisioner' require_relative '../auth' require_relative '../clock' require_relative '../message_types' +require_relative 'credential_registry' module Arcp module Runtime @@ -19,20 +21,34 @@ module Runtime class Runtime attr_reader :auth_verifier, :clock, :name, :version, :heartbeat_interval_sec, :resume_window_sec, - :job_manager, :lease_manager, :subscription_manager, :event_log + :job_manager, :lease_manager, :subscription_manager, + :event_log, :credential_registry, :enforce_model_use def initialize(auth_verifier:, name: 'arcp-runtime', version: Arcp::VERSION, heartbeat_interval_sec: 30, resume_window_sec: 300, - clock: Arcp::SystemClock.new) + clock: Arcp::SystemClock.new, credential_provisioner: nil, + credential_store: nil, require_durable_store: false, + enforce_model_use: false) + if require_durable_store && credential_provisioner && credential_store.nil? + raise Arcp::Errors::InvalidRequest, + 'provisioned_credentials requires a CredentialStore' + end + @auth_verifier = auth_verifier @name = name @version = version @heartbeat_interval_sec = heartbeat_interval_sec @resume_window_sec = resume_window_sec @clock = clock + @enforce_model_use = enforce_model_use + @credential_registry = build_credential_registry( + credential_provisioner: credential_provisioner, + credential_store: credential_store, + clock: clock + ) @event_log = EventLog.new(window_sec: resume_window_sec, clock: clock) - @lease_manager = LeaseManager.new(clock: clock) + @lease_manager = LeaseManager.new(clock: clock, enforce_model_use: enforce_model_use) @subscription_manager = SubscriptionManager.new @job_manager = JobManager.new( runtime: self, @@ -50,7 +66,16 @@ def register_agent(name:, versions:, default:, handler:) end def local_capabilities(agents_inventory: false) + features = Arcp::Session::Feature::ALL.dup + unless @credential_registry + features -= [ + Arcp::Session::Feature::MODEL_USE, + Arcp::Session::Feature::PROVISIONED_CREDENTIALS + ] + end + Arcp::Session::CapabilitySet.local( + features: features, agents: agents_inventory ? @job_manager.agent_inventory : nil ) end @@ -77,6 +102,17 @@ def shutdown(reason: nil) private + def build_credential_registry(credential_provisioner:, credential_store:, clock:) + return nil unless credential_provisioner + + store = credential_store || Arcp::Credentials::InMemoryStore.new + CredentialRegistry.new( + provisioner: credential_provisioner, + store: store, + clock: clock + ).tap(&:reconcile_on_startup!) + end + def bye_envelope(session_id, reason) Arcp::Envelope.build( type: Arcp::MessageTypes::SESSION_BYE, diff --git a/lib/arcp/runtime/session_actor.rb b/lib/arcp/runtime/session_actor.rb index a7b7958..c0232b8 100644 --- a/lib/arcp/runtime/session_actor.rb +++ b/lib/arcp/runtime/session_actor.rb @@ -198,17 +198,21 @@ def handle_submit(env) session_id: @session_id, session_actor: self ) if result.is_a?(Array) - job_id, resolved_agent, lease = result + job_id, resolved_agent, lease, credentials = result accepted = Arcp::Job::Accepted.new( job_id: job_id, agent: resolved_agent, - accepted_at: @runtime.clock.now.iso8601, lease: lease + accepted_at: @runtime.clock.now.iso8601, + lease: lease, + credentials: credentials ) else job_id = result record = @runtime.job_manager.lookup(job_id) accepted = Arcp::Job::Accepted.new( job_id: job_id, agent: record.agent, - accepted_at: record.created_at, lease: @runtime.lease_manager.get(job_id) + accepted_at: record.created_at, + lease: @runtime.lease_manager.get(job_id), + credentials: nil ) end send_envelope(Arcp::Envelope.build( diff --git a/lib/arcp/session/feature.rb b/lib/arcp/session/feature.rb index c1943e5..a28a75f 100644 --- a/lib/arcp/session/feature.rb +++ b/lib/arcp/session/feature.rb @@ -12,10 +12,13 @@ module Feature PROGRESS = 'progress' RESULT_CHUNK = 'result_chunk' AGENT_VERSIONS = 'agent_versions' + MODEL_USE = 'model.use' + PROVISIONED_CREDENTIALS = 'provisioned_credentials' ALL = [ HEARTBEAT, ACK, LIST_JOBS, SUBSCRIBE, LEASE_EXPIRES_AT, - COST_BUDGET, PROGRESS, RESULT_CHUNK, AGENT_VERSIONS + COST_BUDGET, PROGRESS, RESULT_CHUNK, AGENT_VERSIONS, + MODEL_USE, PROVISIONED_CREDENTIALS ].freeze end end diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..688185a --- /dev/null +++ b/samples/README.md @@ -0,0 +1,57 @@ +# Samples + +Each subdirectory is a self-contained runnable example with three files: + +| File | Purpose | +| --- | --- | +| `server.rb` | Sets up `Arcp::Runtime::Runtime`, registers the agent, and starts a transport. | +| `client.rb` | Connects, submits a job, observes events, and prints the result. | +| `run.rb` | Wires the two together with `MemoryTransport.pair` and runs them under `Sync { }`. | + +## Running an example + +``` +bundle exec ruby samples//run.rb +``` + +All examples depend only on the gems in the repo `Gemfile`. + +## Samples + +| Directory | What it demonstrates | Spec | +| --- | --- | --- | +| `ack_backpressure` | `session.ack` and backpressure signalling | §6.5, §14 | +| `agent_versions` | `name@version` pinning and `AGENT_VERSION_NOT_AVAILABLE` | §7.5 | +| `cancel` | Client-initiated cancellation mid-flight | §7 | +| `cost_budget` | `cost.budget` capability with `try_spend!` and `BudgetExhausted` | §9.6 | +| `custom_auth` | Custom `AuthScheme` implementation replacing `Bearer` | §6.1 | +| `delegate` | Handler emitting a `delegate` event with a subset lease | §10 | +| `heartbeat` | `heartbeat_interval_sec` ping/pong keepalive | §6.4 | +| `idempotent_retry` | `idempotency_key` reuse returning the same `job_id` | §13 | +| `lease_expires_at` | Lease with `expires_at` and `LeaseExpired` on overshoot | §9.3, §9.5 | +| `lease_violation` | `LeaseSubsetViolation` raised from `Subsetting.bound` | §9.4 | +| `list_jobs` | Paginated `list_jobs` with cursor walking | §6.6 | +| `progress` | Periodic `progress` events consumed via subscribe | §8.3 | +| `provisioned_credentials` | `InMemoryProvisioner` issuing + revoking scoped keys | §9.7–§9.8 | +| `result_chunk` | `stream_result` producer and chunk-assembling consumer | §8.4 | +| `resume` | Transport drop, reconnect with `resume_token` + `last_event_seq` | §6.3 | +| `stdio` | Runtime and client connected over `StdioTransport` (child process) | §5 | +| `submit_and_stream` | Minimal submit → subscribe → get_result end-to-end | §7 | +| `subscribe` | Cross-session observation (`client_b` watching `client_a`'s job) | §7.6 | +| `vendor_extensions` | Emitting and receiving `x-vendor.*` event kinds | §15 | + +## TypeScript-only examples not ported + +The TypeScript SDK ships additional examples that target Node.js-specific HTTP +server integrations. These are intentionally not ported — Ruby uses different +server libraries with their own adapter patterns. + +| TS example | Why not ported | Ruby equivalent | +| --- | --- | --- | +| `bun` | Bun is a JavaScript runtime | Use `falcon` or any Rack-compatible server — see [`docs/deployment.md`](../docs/guides/deployment.md) | +| `express` | Express is a Node.js framework | Use Sinatra: `gem 'sinatra'` + `Async::WebSocket::Adapters::Rack` | +| `fastify` | Fastify is a Node.js framework | Use Rack middleware or Roda with the `websockets` plugin | +| `hono` | Hono targets JS runtimes (Bun, Deno, CF Workers) | No direct equivalent; use Rack or Falcon directly | + +For WebSocket server integration in Ruby see [`docs/guides/deployment.md`](../docs/guides/deployment.md). +For stdio-based child-process agents see the [`stdio`](stdio/) sample. diff --git a/samples/_harness.rb b/samples/_harness.rb index 22dd559..c65a7e3 100644 --- a/samples/_harness.rb +++ b/samples/_harness.rb @@ -15,20 +15,20 @@ module Harness StderrLogger = Logger.new( $stderr, level: Logger::INFO, - formatter: ->(sev, _, _, msg) { "[#{sev}] #{msg}\n" } + formatter: ->(sev, _, _, msg) { "[#{sev}] #{msg}\n" } ) - def run_or_exit(name, &block) + def run_or_exit(name) emitted = false - yielder = ->(asserts) { + yielder = lambda do |asserts| raise 'emit called twice' if emitted emitted = true $stdout.puts JSON.dump({ 'sample' => name, 'ok' => true, 'asserts' => asserts }) - } + end Sync do - block.call(yielder) + yield yielder end raise 'sample did not emit' unless emitted @@ -62,14 +62,15 @@ def fake_clock(start: '2026-01-01T00:00:00Z') Arcp::FakeClock.new(now: Time.iso8601(start)) end - def runtime(agents: {}, auth_tokens: { 'demo' => 'alice' }, heartbeat_interval_sec: nil, clock: nil) + def runtime(agents: {}, auth_tokens: { 'demo' => 'alice' }, heartbeat_interval_sec: nil, clock: nil, **kw) tokens = auth_tokens.transform_values do |id| Arcp::Auth::Principal.new(id: id, name: id, scopes: [].freeze) end r = Arcp::Runtime::Runtime.new( auth_verifier: Arcp::Auth::Bearer.new(tokens: tokens), heartbeat_interval_sec: heartbeat_interval_sec, - clock: clock || Arcp::SystemClock.new + clock: clock || Arcp::SystemClock.new, + **kw ) agents.each do |name, spec| if spec.is_a?(Hash) diff --git a/samples/provisioned_credentials/client.rb b/samples/provisioned_credentials/client.rb new file mode 100644 index 0000000..b097635 --- /dev/null +++ b/samples/provisioned_credentials/client.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative '../_harness' + +module ProvisionedCredentialsSample + module Client + def self.run(client) + handle = client.submit_job( + agent: 'gateway-caller', + lease_request: Arcp::Lease::LeaseRequest.new( + capabilities: ['cost.spend'], + budget: Arcp::Lease::CostBudget.parse(['USD:1.00']), + model_use: ['tier-fast/*'] + ) + ) + result = handle.get_result(client: client) + [handle, result] + end + end +end diff --git a/samples/provisioned_credentials/run.rb b/samples/provisioned_credentials/run.rb new file mode 100644 index 0000000..443c54b --- /dev/null +++ b/samples/provisioned_credentials/run.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative 'server' +require_relative 'client' + +code = Harness.run_or_exit('provisioned_credentials') do |emit| + server_t, client_t = Harness.pair_memory + runtime = ProvisionedCredentialsSample.runtime + client, task = Harness.open_client(server_t, client_t, runtime, client_name: 'provisioned-credentials') + + handle, result = ProvisionedCredentialsSample::Client.run(client) + emit.call( + 'job_id' => handle.job_id, + 'result' => result.result, + 'credentials' => handle.credentials.map(&:to_redacted_h), + 'revoked' => ProvisionedCredentialsSample.provisioner.revoked + ) + client.close + task.stop +end +exit code diff --git a/samples/provisioned_credentials/server.rb b/samples/provisioned_credentials/server.rb new file mode 100644 index 0000000..c344621 --- /dev/null +++ b/samples/provisioned_credentials/server.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative '../_harness' + +module ProvisionedCredentialsSample + HANDLER = lambda do |ctx| + ctx.status(phase: 'using_gateway') + ctx.finish(result: { 'credential_count' => 1 }) + end + + def self.provisioner + @provisioner ||= Arcp::Credentials::InMemoryProvisioner.new( + endpoint: 'https://llm-gateway.example/v1', + profile: 'openai' + ) + end + + def self.runtime + Harness.runtime( + agents: { 'gateway-caller' => HANDLER }, + credential_provisioner: provisioner, + credential_store: Arcp::Credentials::InMemoryStore.new + ) + end +end diff --git a/sig/arcp/credential.rbs b/sig/arcp/credential.rbs new file mode 100644 index 0000000..d56ade0 --- /dev/null +++ b/sig/arcp/credential.rbs @@ -0,0 +1,51 @@ +module Arcp + class Credential + SCHEME_BEARER: String + + attr_reader id: String + attr_reader scheme: String + attr_reader value: String + attr_reader endpoint: String + attr_reader profile: String? + attr_reader constraints: Hash[String, untyped] + + def self.from_h: (Hash[String | Symbol, untyped]) -> Credential + def to_h: () -> Hash[String, untyped] + def to_redacted_h: () -> Hash[String, untyped] + end + + module ModelPattern + def self.match?: (Array[String]?, String) -> bool + def self.implied_by?: (Array[String]?, String) -> bool + end + + module CredentialProvisioner + def issue: (lease: Arcp::Lease::Lease?, job_id: String, agent: String, principal_id: String) -> Array[Credential] + def revoke: (credential_id: String) -> void + end + + module Credentials + def self.translate_upstream_error: (StandardError) -> StandardError + def self.budget_exhausted?: (StandardError) -> bool + + class InMemoryProvisioner + include CredentialProvisioner + + attr_reader issued: Array[Hash[Symbol, untyped]] + attr_reader revoked: Array[String] + + def initialize: (?endpoint: String, ?profile: String) -> void + end + + class CredentialStore + def record: (job_id: String, credential_id: String) -> void + def forget: (job_id: String, credential_id: String) -> void + def outstanding: (job_id: String) -> Array[String] + def all_outstanding: () -> Hash[String, Array[String]] + end + + class InMemoryStore < CredentialStore + def initialize: () -> void + end + end +end diff --git a/sig/arcp/job.rbs b/sig/arcp/job.rbs index 5fb7af5..ab3e05a 100644 --- a/sig/arcp/job.rbs +++ b/sig/arcp/job.rbs @@ -34,9 +34,21 @@ module Arcp attr_reader agent: String attr_reader submitted_at: String? attr_reader lease: Arcp::Lease::Lease? + attr_reader credentials: Array[Arcp::Credential]? def subscribe: (client: Arcp::Client, **untyped) -> Enumerator[Event, void] def cancel: (client: Arcp::Client, ?reason: String?) -> Envelope def get_result: (client: Arcp::Client) -> Result + def credential_for: (endpoint: String) -> Arcp::Credential? + end + + class Accepted + attr_reader job_id: String + attr_reader agent: String + attr_reader accepted_at: String? + attr_reader lease: Arcp::Lease::Lease? + attr_reader credentials: Array[Arcp::Credential]? + def self.from_h: (Hash[String | Symbol, untyped]) -> Accepted + def to_h: () -> Hash[String, untyped] end class Summary diff --git a/sig/arcp/lease.rbs b/sig/arcp/lease.rbs index 7be17e4..b653707 100644 --- a/sig/arcp/lease.rbs +++ b/sig/arcp/lease.rbs @@ -20,6 +20,7 @@ module Arcp class LeaseRequest attr_reader capabilities: Array[String] attr_reader budget: CostBudget? + attr_reader model_use: Array[String]? attr_reader expires_at: String? def self.from_h: (Hash[String | Symbol, untyped]?) -> LeaseRequest? def to_h: () -> Hash[String, untyped] @@ -29,6 +30,7 @@ module Arcp attr_reader id: String attr_reader capabilities: Array[String] attr_reader budget: CostBudget? + attr_reader model_use: Array[String]? attr_reader expires_at: String? attr_reader issued_at: String def self.from_h: (Hash[String | Symbol, untyped]) -> Lease diff --git a/sig/arcp/runtime.rbs b/sig/arcp/runtime.rbs index 4e18ec7..3792358 100644 --- a/sig/arcp/runtime.rbs +++ b/sig/arcp/runtime.rbs @@ -11,8 +11,10 @@ module Arcp attr_reader lease_manager: LeaseManager attr_reader subscription_manager: SubscriptionManager attr_reader event_log: EventLog + attr_reader credential_registry: CredentialRegistry? + attr_reader enforce_model_use: bool - def initialize: (auth_verifier: untyped, ?name: String, ?version: String, ?heartbeat_interval_sec: Integer?, ?resume_window_sec: Integer, ?clock: untyped) -> void + def initialize: (auth_verifier: untyped, ?name: String, ?version: String, ?heartbeat_interval_sec: Integer?, ?resume_window_sec: Integer, ?clock: untyped, ?credential_provisioner: Arcp::CredentialProvisioner?, ?credential_store: Arcp::Credentials::CredentialStore?, ?require_durable_store: bool, ?enforce_model_use: bool) -> void def register_agent: (name: String, versions: Array[String], default: String, handler: ^(JobContext) -> void) -> void def local_capabilities: (?agents_inventory: bool) -> Arcp::Session::CapabilitySet def accept: (Arcp::Transport::Base) -> untyped @@ -20,10 +22,20 @@ module Arcp end class JobManager end - class LeaseManager end + class LeaseManager + def check_model!: (String, model_id: String) -> bool + end + class CredentialRegistry + def issue_for: (job_id: String, lease: Arcp::Lease::Lease?, agent: String, principal_id: String) -> Array[Arcp::Credential] + def rotate: (job_id: String, credential_id: String, new_value: String) -> String + def revoke_all: (job_id: String) -> Integer + def reconcile_on_startup!: () -> void + end class SubscriptionManager end class EventLog end - class JobContext end + class JobContext + def rotate_credential: (id: String, new_value: String) -> untyped + end class SessionActor end end end diff --git a/spec/integration/model_use_spec.rb b/spec/integration/model_use_spec.rb new file mode 100644 index 0000000..0e3f0ba --- /dev/null +++ b/spec/integration/model_use_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'model.use enforcement', type: :integration do + it 'rejects models outside model.use' do + Sync do + runtime = build_runtime( + agents: { llm: lambda { |ctx| + runtime.lease_manager.check_model!(ctx.job_id, model_id: 'anthropic/claude-3-opus') + ctx.finish(result: 'ok') + } } + ) + client, server_task = open_pair(runtime) + + handle = client.submit_job(agent: 'llm', lease_request: model_lease(['tier-fast/*'])) + + expect { handle.get_result(client: client) }.to raise_error(Arcp::Errors::PermissionDenied) + client.close + server_task.stop + end + end + + it 'admits models matching model.use' do + Sync do + runtime = build_runtime( + agents: { llm: lambda { |ctx| + runtime.lease_manager.check_model!(ctx.job_id, model_id: 'tier-fast/gpt-4o-mini') + ctx.finish(result: 'ok') + } } + ) + client, server_task = open_pair(runtime) + + handle = client.submit_job(agent: 'llm', lease_request: model_lease(['tier-fast/*'])) + + expect(handle.get_result(client: client).result).to eq('ok') + client.close + server_task.stop + end + end + + def model_lease(patterns) + Arcp::Lease::LeaseRequest.new( + capabilities: ['model.call'], + model_use: patterns + ) + end +end diff --git a/spec/integration/provisioned_credentials_spec.rb b/spec/integration/provisioned_credentials_spec.rb new file mode 100644 index 0000000..6397d50 --- /dev/null +++ b/spec/integration/provisioned_credentials_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'provisioned credentials', type: :integration do + it 'attaches credentials to job.accepted and client handles' do + Sync do + provisioner = Arcp::Credentials::InMemoryProvisioner.new + runtime = build_runtime( + agents: { echo: ->(ctx) { ctx.finish(result: ctx.input) } }, + credential_provisioner: provisioner + ) + client, server_task = open_pair(runtime) + + handle = client.submit_job(agent: 'echo', input: { 'ok' => true }) + + expect(handle.credentials.first.value).to eq("sk-test-#{handle.job_id}") + expect(handle.credential_for(endpoint: 'https://gateway.test/v1').scheme).to eq('bearer') + handle.get_result(client: client) + client.close + server_task.stop + end + end + + it 'revokes credentials on success' do + Sync do + provisioner = Arcp::Credentials::InMemoryProvisioner.new + runtime = build_runtime( + agents: { echo: ->(ctx) { ctx.finish(result: 'ok') } }, + credential_provisioner: provisioner + ) + client, server_task = open_pair(runtime) + + handle = client.submit_job(agent: 'echo') + handle.get_result(client: client) + + expect(provisioner.revoked).to include("cred_#{handle.job_id}_0") + client.close + server_task.stop + end + end + + it 'revokes credentials on error' do + Sync do + provisioner = Arcp::Credentials::InMemoryProvisioner.new + runtime = build_runtime( + agents: { failer: ->(ctx) { ctx.fail!(code: 'INTERNAL_ERROR') } }, + credential_provisioner: provisioner + ) + client, server_task = open_pair(runtime) + + handle = client.submit_job(agent: 'failer') + expect { handle.get_result(client: client) }.to raise_error(Arcp::Errors::Internal) + + expect(provisioner.revoked).to include("cred_#{handle.job_id}_0") + client.close + server_task.stop + end + end + + it 'revokes credentials on cancellation' do + Sync do + provisioner = Arcp::Credentials::InMemoryProvisioner.new + runtime = build_runtime( + agents: { sleepy: ->(_ctx) { Async::Task.current.sleep(5) } }, + credential_provisioner: provisioner + ) + client, server_task = open_pair(runtime) + + handle = client.submit_job(agent: 'sleepy') + Async::Task.current.sleep(0.05) + handle.cancel(client: client, reason: 'stop') + expect { handle.get_result(client: client) }.to raise_error(Arcp::Errors::Cancelled) + + expect(provisioner.revoked).to include("cred_#{handle.job_id}_0") + client.close + server_task.stop + end + end + + it 'revokes credentials on timeout' do + Sync do + provisioner = Arcp::Credentials::InMemoryProvisioner.new + runtime = build_runtime( + agents: { sleepy: ->(_ctx) { Async::Task.current.sleep(5) } }, + credential_provisioner: provisioner + ) + client, server_task = open_pair(runtime) + + handle = client.submit_job(agent: 'sleepy', max_runtime_sec: 0.01) + expect { handle.get_result(client: client) }.to raise_error(Arcp::Errors::Timeout) + + expect(provisioner.revoked).to include("cred_#{handle.job_id}_0") + client.close + server_task.stop + end + end + + it 'emits credential rotation status and revokes the prior id' do + Sync do + provisioner = Arcp::Credentials::InMemoryProvisioner.new + runtime = build_runtime( + agents: { rotator: lambda { |ctx| + Async::Task.current.sleep(0.05) + ctx.rotate_credential(id: "cred_#{ctx.job_id}_0", new_value: 'sk-rotated') + ctx.finish(result: 'rotated') + } }, + credential_provisioner: provisioner + ) + client, server_task = open_pair(runtime) + + handle = client.submit_job(agent: 'rotator') + events = handle.subscribe(client: client).to_a + event = events.find { |item| item.body.respond_to?(:phase) && item.body.phase == 'credential_rotated' } + + expect(event.body.fields['value']).to eq('sk-rotated') + expect(provisioner.revoked).to include("cred_#{handle.job_id}_0") + client.close + server_task.stop + end + end + + it 'does not expose credentials through list_jobs' do + Sync do + provisioner = Arcp::Credentials::InMemoryProvisioner.new + runtime = build_runtime( + agents: { echo: ->(ctx) { ctx.finish(result: 'ok') } }, + credential_provisioner: provisioner + ) + client, server_task = open_pair(runtime) + + handle = client.submit_job(agent: 'echo') + handle.get_result(client: client) + summary = client.list_jobs.to_a.first + + expect(summary.to_h).not_to have_key('credentials') + client.close + server_task.stop + end + end +end diff --git a/spec/unit/capability_negotiation_spec.rb b/spec/unit/capability_negotiation_spec.rb index 7dc22ec..01ef099 100644 --- a/spec/unit/capability_negotiation_spec.rb +++ b/spec/unit/capability_negotiation_spec.rb @@ -23,6 +23,40 @@ end end +RSpec.describe Arcp::Runtime::Runtime do + let(:auth) do + principal = Arcp::Auth::Principal.new(id: 'alice', name: 'alice', scopes: [].freeze) + Arcp::Auth::Bearer.new(tokens: { 'demo' => principal }) + end + + it 'does not advertise provisioned credentials without a provisioner' do + runtime = described_class.new(auth_verifier: auth) + + expect(runtime.local_capabilities.features).not_to include('model.use') + expect(runtime.local_capabilities.features).not_to include('provisioned_credentials') + end + + it 'advertises provisioned credentials when a provisioner is configured' do + runtime = described_class.new( + auth_verifier: auth, + credential_provisioner: Arcp::Credentials::InMemoryProvisioner.new + ) + + expect(runtime.local_capabilities.features).to include('model.use') + expect(runtime.local_capabilities.features).to include('provisioned_credentials') + end + + it 'rejects durable revocation mode without a store' do + expect do + described_class.new( + auth_verifier: auth, + credential_provisioner: Arcp::Credentials::InMemoryProvisioner.new, + require_durable_store: true + ) + end.to raise_error(Arcp::Errors::InvalidRequest) + end +end + RSpec.describe Arcp::Session::AgentInventory do let(:inv) do described_class.from_array([ diff --git a/spec/unit/credential_registry_spec.rb b/spec/unit/credential_registry_spec.rb new file mode 100644 index 0000000..3551ec7 --- /dev/null +++ b/spec/unit/credential_registry_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Arcp::Runtime::CredentialRegistry do + it 'records issued credential ids in the store' do + provisioner = Arcp::Credentials::InMemoryProvisioner.new + store = Arcp::Credentials::InMemoryStore.new + registry = described_class.new(provisioner: provisioner, store: store) + + credentials = registry.issue_for(job_id: 'job_1', lease: nil, agent: 'echo', principal_id: 'alice') + + expect(credentials.first.id).to eq('cred_job_1_0') + expect(store.outstanding(job_id: 'job_1')).to eq(['cred_job_1_0']) + end + + it 'maps upstream budget exhaustion to the ARCP budget error' do + stub_const('UpstreamBudgetError', upstream_budget_error_class) + error = UpstreamBudgetError.new('gateway budget exhausted') + + translated = Arcp::Credentials.translate_upstream_error(error) + + expect(translated).to be_a(Arcp::Errors::BudgetExhausted) + expect(translated.details['upstream_class']).to include('UpstreamBudgetError') + end + + it 'revokes all outstanding credentials and forgets successful revocations' do + provisioner = Arcp::Credentials::InMemoryProvisioner.new + store = Arcp::Credentials::InMemoryStore.new + registry = described_class.new(provisioner: provisioner, store: store) + + registry.issue_for(job_id: 'job_1', lease: nil, agent: 'echo', principal_id: 'alice') + + expect(registry.revoke_all(job_id: 'job_1')).to eq(1) + expect(provisioner.revoked).to eq(['cred_job_1_0']) + expect(store.outstanding(job_id: 'job_1')).to be_empty + end + + it 'retries transient revoke failures once' do + provisioner = flaky_provisioner_class.new + store = Arcp::Credentials::InMemoryStore.new + store.record(job_id: 'job_1', credential_id: 'cred_1') + registry = described_class.new(provisioner: provisioner, store: store) + + expect(registry.revoke_all(job_id: 'job_1')).to eq(1) + expect(provisioner.revoked).to eq(['cred_1']) + expect(store.outstanding(job_id: 'job_1')).to be_empty + end + + it 'reconciles outstanding credentials on startup' do + provisioner = Arcp::Credentials::InMemoryProvisioner.new + store = Arcp::Credentials::InMemoryStore.new + store.record(job_id: 'job_1', credential_id: 'cred_1') + + described_class.new(provisioner: provisioner, store: store).reconcile_on_startup! + + expect(provisioner.revoked).to eq(['cred_1']) + expect(store.all_outstanding).to be_empty + end + + def flaky_provisioner_class + Class.new do + include Arcp::CredentialProvisioner + + attr_reader :revoked + + def initialize + self.attempts = Hash.new(0) + self.revoked = [] + end + + def issue(lease:, job_id:, agent:, principal_id:) + [] + end + + def revoke(credential_id:) + attempts[credential_id] += 1 + raise 'transient' if attempts[credential_id] == 1 + + revoked << credential_id + end + + private + + attr_accessor :attempts + attr_writer :revoked + end + end + + def upstream_budget_error_class + Class.new(StandardError) do + def code = 'budget_exhausted' + end + end +end diff --git a/spec/unit/credential_spec.rb b/spec/unit/credential_spec.rb new file mode 100644 index 0000000..862d8c2 --- /dev/null +++ b/spec/unit/credential_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Arcp::Credential do + it 'round-trips the provisioned credential wire shape' do + credential = described_class.from_h( + 'id' => 'cred_1', + 'scheme' => 'bearer', + 'value' => 'sk-test', + 'endpoint' => 'https://gateway.test/v1', + 'profile' => 'openai', + 'constraints' => { 'model.use' => ['tier-fast/*'] } + ) + + expect(credential.to_h).to include( + 'id' => 'cred_1', + 'value' => 'sk-test', + 'constraints' => { 'model.use' => ['tier-fast/*'] } + ) + end + + it 'redacts credential values for non-wire surfaces' do + credential = described_class.new( + id: 'cred_1', + scheme: 'bearer', + value: 'sk-test', + endpoint: 'https://gateway.test/v1' + ) + + expect(credential.to_redacted_h['value']).to eq('[REDACTED]') + end +end + +RSpec.describe Arcp::ModelPattern do + it 'matches model globs' do + expect(described_class.match?(['tier-fast/*'], 'tier-fast/gpt-4o')).to be(true) + expect(described_class.match?(['anthropic/claude-3-haiku-*'], 'anthropic/claude-3-opus')).to be(false) + expect(described_class.match?(nil, 'tier-fast/gpt-4o')).to be(false) + end + + it 'allows child literals under a parent model glob' do + expect(described_class.implied_by?(['tier-fast/*'], 'tier-fast/gpt-4o')).to be(true) + expect(described_class.implied_by?(['tier-fast/*'], 'anthropic/claude-3-opus')).to be(false) + end +end diff --git a/spec/unit/lease_spec.rb b/spec/unit/lease_spec.rb index 836130e..deed28e 100644 --- a/spec/unit/lease_spec.rb +++ b/spec/unit/lease_spec.rb @@ -43,11 +43,24 @@ end end +RSpec.describe Arcp::Lease::LeaseRequest do + it 'round-trips model.use patterns' do + request = described_class.from_h( + 'capabilities' => ['cost.spend'], + 'model.use' => ['tier-fast/*'] + ) + + expect(request.model_use).to eq(['tier-fast/*']) + expect(request.to_h['model.use']).to eq(['tier-fast/*']) + end +end + RSpec.describe Arcp::Lease::Subsetting do let(:parent) do Arcp::Lease::Lease.new( id: 'lse_p', capabilities: %w[fs.read net.fetch], budget: Arcp::Lease::CostBudget.parse(['USD:5.00']), + model_use: ['tier-fast/*'], expires_at: '2026-05-14T12:00:00Z', issued_at: '2026-05-14T10:00:00Z' ) end @@ -79,4 +92,24 @@ expect(child.expires_at).to eq('2026-05-14T11:30:00Z') expect(child.budget.remaining('USD')).to eq(BigDecimal('2.00')) end + + it 'rejects model.use expansion' do + request = Arcp::Lease::LeaseRequest.new( + capabilities: %w[fs.read], + model_use: ['anthropic/*'] + ) + + expect { described_class.bound(parent: parent, request: request) } + .to raise_error(Arcp::Errors::LeaseSubsetViolation) + end + + it 'allows a child model literal inside a parent model glob' do + request = Arcp::Lease::LeaseRequest.new( + capabilities: %w[fs.read], + model_use: ['tier-fast/gpt-4o'] + ) + + child = described_class.bound(parent: parent, request: request) + expect(child.model_use).to eq(['tier-fast/gpt-4o']) + end end