arcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +31 -0
  3. data/CONFORMANCE.md +71 -0
  4. data/LICENSE +202 -0
  5. data/README.md +135 -0
  6. data/lib/arcp/auth/auth_scheme.rb +16 -0
  7. data/lib/arcp/auth/bearer.rb +38 -0
  8. data/lib/arcp/auth.rb +4 -0
  9. data/lib/arcp/client.rb +354 -0
  10. data/lib/arcp/clock.rb +35 -0
  11. data/lib/arcp/credential.rb +65 -0
  12. data/lib/arcp/credential_provisioner.rb +132 -0
  13. data/lib/arcp/envelope.rb +115 -0
  14. data/lib/arcp/errors.rb +154 -0
  15. data/lib/arcp/ids.rb +18 -0
  16. data/lib/arcp/job/accepted.rb +37 -0
  17. data/lib/arcp/job/agent_ref.rb +18 -0
  18. data/lib/arcp/job/cancel.rb +18 -0
  19. data/lib/arcp/job/event.rb +68 -0
  20. data/lib/arcp/job/event_body/delegate.rb +24 -0
  21. data/lib/arcp/job/event_body/log.rb +20 -0
  22. data/lib/arcp/job/event_body/metric.rb +20 -0
  23. data/lib/arcp/job/event_body/progress.rb +27 -0
  24. data/lib/arcp/job/event_body/result_chunk.rb +42 -0
  25. data/lib/arcp/job/event_body/status.rb +25 -0
  26. data/lib/arcp/job/event_body/thought.rb +12 -0
  27. data/lib/arcp/job/event_body/tool_call.rb +16 -0
  28. data/lib/arcp/job/event_body/tool_result.rb +23 -0
  29. data/lib/arcp/job/event_body/trace_span.rb +30 -0
  30. data/lib/arcp/job/handle.rb +16 -0
  31. data/lib/arcp/job/job_error.rb +31 -0
  32. data/lib/arcp/job/result.rb +30 -0
  33. data/lib/arcp/job/submit.rb +32 -0
  34. data/lib/arcp/job/subscribe.rb +22 -0
  35. data/lib/arcp/job/subscribed.rb +14 -0
  36. data/lib/arcp/job/summary.rb +27 -0
  37. data/lib/arcp/job/unsubscribe.rb +10 -0
  38. data/lib/arcp/job.rb +15 -0
  39. data/lib/arcp/lease.rb +212 -0
  40. data/lib/arcp/message_types.rb +35 -0
  41. data/lib/arcp/runtime/credential_registry.rb +67 -0
  42. data/lib/arcp/runtime/event_log.rb +62 -0
  43. data/lib/arcp/runtime/job_context.rb +167 -0
  44. data/lib/arcp/runtime/job_manager.rb +256 -0
  45. data/lib/arcp/runtime/lease_manager.rb +88 -0
  46. data/lib/arcp/runtime/runtime.rb +125 -0
  47. data/lib/arcp/runtime/session_actor.rb +300 -0
  48. data/lib/arcp/runtime/subscription_manager.rb +57 -0
  49. data/lib/arcp/runtime.rb +10 -0
  50. data/lib/arcp/serializer.rb +43 -0
  51. data/lib/arcp/session/ack.rb +14 -0
  52. data/lib/arcp/session/agent_inventory.rb +51 -0
  53. data/lib/arcp/session/bye.rb +10 -0
  54. data/lib/arcp/session/capability_set.rb +29 -0
  55. data/lib/arcp/session/feature.rb +25 -0
  56. data/lib/arcp/session/hello.rb +34 -0
  57. data/lib/arcp/session/jobs_response.rb +18 -0
  58. data/lib/arcp/session/list_jobs.rb +19 -0
  59. data/lib/arcp/session/ping.rb +14 -0
  60. data/lib/arcp/session/pong.rb +14 -0
  61. data/lib/arcp/session/session_error.rb +23 -0
  62. data/lib/arcp/session/welcome.rb +38 -0
  63. data/lib/arcp/session.rb +26 -0
  64. data/lib/arcp/trace.rb +51 -0
  65. data/lib/arcp/transport/base.rb +29 -0
  66. data/lib/arcp/transport/memory_transport.rb +54 -0
  67. data/lib/arcp/transport/stdio_transport.rb +56 -0
  68. data/lib/arcp/transport/websocket_transport.rb +47 -0
  69. data/lib/arcp/transport.rb +6 -0
  70. data/lib/arcp/version.rb +7 -0
  71. data/lib/arcp.rb +19 -0
  72. data/sig/arcp/client.rbs +16 -0
  73. data/sig/arcp/credential.rbs +51 -0
  74. data/sig/arcp/envelope.rbs +25 -0
  75. data/sig/arcp/errors.rbs +40 -0
  76. data/sig/arcp/job.rbs +83 -0
  77. data/sig/arcp/lease.rbs +41 -0
  78. data/sig/arcp/runtime.rbs +41 -0
  79. data/sig/arcp/serializer.rbs +8 -0
  80. data/sig/arcp/session.rbs +56 -0
  81. data/sig/arcp/transport.rbs +18 -0
  82. data/sig/arcp.rbs +5 -0
  83. metadata +226 -0
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+ require 'async/queue'
5
+ require 'time'
6
+
7
+ module Arcp
8
+ module Runtime
9
+ AgentRegistration = Data.define(:name, :versions, :default, :handler)
10
+
11
+ JobRecord = Data.define(:job_id, :agent, :principal_id, :status, :created_at,
12
+ :input, :submitter_session_id, :task) do
13
+ def with(**kw) = self.class.new(**to_h, **kw)
14
+ end
15
+
16
+ # Owns agent registry + per-job lifecycle. Submitted jobs run as
17
+ # child `Async::Task`s; cancellation propagates via `task.stop`.
18
+ class JobManager
19
+ attr_reader :runtime
20
+
21
+ def initialize(runtime:, lease_manager:, subscription_manager:, event_log:, clock: Arcp::SystemClock.new)
22
+ @runtime = runtime
23
+ @leases = lease_manager
24
+ @subs = subscription_manager
25
+ @event_log = event_log
26
+ @clock = clock
27
+ @agents = {} # name => AgentRegistration
28
+ @jobs = {} # job_id => JobRecord
29
+ @event_seq = Hash.new(0) # job_id => last emitted seq
30
+ @idempotency = {} # [principal, key] => job_id
31
+ @mutex = Mutex.new
32
+ end
33
+
34
+ def register_agent(name:, versions:, default:, handler:)
35
+ @mutex.synchronize do
36
+ @agents[name] = AgentRegistration.new(
37
+ name: name, versions: Array(versions).freeze,
38
+ default: default, handler: handler
39
+ )
40
+ end
41
+ end
42
+
43
+ def agent_inventory
44
+ @mutex.synchronize do
45
+ Arcp::Session::AgentInventory.new(
46
+ entries: @agents.values.map do |reg|
47
+ Arcp::Session::AgentEntry.new(name: reg.name, versions: reg.versions, default: reg.default)
48
+ end.freeze
49
+ )
50
+ end
51
+ end
52
+
53
+ def resolve_agent(ref_str)
54
+ ref = Arcp::Job::AgentRef.parse(ref_str)
55
+ reg = @mutex.synchronize { @agents[ref.name] }
56
+ raise Arcp::Errors::AgentNotAvailable, "agent not registered: #{ref.name}" if reg.nil?
57
+
58
+ version = ref.version || reg.default
59
+ if version.nil? || (!reg.versions.empty? && !reg.versions.include?(version))
60
+ raise Arcp::Errors::AgentVersionNotAvailable.new(
61
+ "agent #{ref.name} has no version #{version.inspect}",
62
+ details: { 'agent' => ref.name, 'version' => version, 'available' => reg.versions }
63
+ )
64
+ end
65
+
66
+ [reg, "#{ref.name}@#{version}"]
67
+ end
68
+
69
+ def submit(submit:, principal_id:, session_id:, session_actor:)
70
+ reg, resolved = resolve_agent(submit.agent)
71
+
72
+ if submit.idempotency_key
73
+ key = [principal_id, submit.idempotency_key]
74
+ if (existing = @mutex.synchronize { @idempotency[key] })
75
+ existing_record = @mutex.synchronize { @jobs[existing] }
76
+ if existing_record && existing_record.agent != resolved
77
+ raise Arcp::Errors::DuplicateKey.new(
78
+ 'idempotency key reused with different agent',
79
+ details: { 'job_id' => existing }
80
+ )
81
+ end
82
+ return existing
83
+ end
84
+ end
85
+
86
+ job_id = Arcp::Ids.job_id
87
+
88
+ lease = build_lease(submit, job_id)
89
+ @leases.register(job_id, lease) if lease
90
+ credentials = issue_credentials(
91
+ job_id: job_id, lease: lease, agent: resolved, principal_id: principal_id
92
+ )
93
+
94
+ record = JobRecord.new(
95
+ job_id: job_id, agent: resolved, principal_id: principal_id,
96
+ status: 'pending', created_at: @clock.now.iso8601,
97
+ input: submit.input, submitter_session_id: session_id, task: nil
98
+ )
99
+ @mutex.synchronize do
100
+ @jobs[job_id] = record
101
+ @idempotency[[principal_id, submit.idempotency_key]] = job_id if submit.idempotency_key
102
+ end
103
+
104
+ @subs.register_owner(job_id, principal_id, session_id, session_actor.outbox)
105
+
106
+ task = Async do |t|
107
+ run_agent(t, reg, job_id, submit, lease)
108
+ end
109
+ @mutex.synchronize { @jobs[job_id] = @jobs[job_id].with(task: task, status: 'running') }
110
+
111
+ [job_id, resolved, lease, credentials]
112
+ end
113
+
114
+ def cancel(job_id:, principal_id:, reason: nil)
115
+ record = @mutex.synchronize { @jobs[job_id] }
116
+ raise Arcp::Errors::JobNotFound, "no such job: #{job_id}" unless record
117
+
118
+ unless record.principal_id == principal_id
119
+ raise Arcp::Errors::PermissionDenied.new(
120
+ 'only the submitting principal can cancel a job',
121
+ details: { 'job_id' => job_id }
122
+ )
123
+ end
124
+
125
+ record.task&.stop
126
+ publish_error(job_id, Arcp::Job::JobError.new(
127
+ job_id: job_id, final_status: 'cancelled',
128
+ code: 'CANCELLED', message: reason, retryable: false, details: {}
129
+ ))
130
+ end
131
+
132
+ def list(principal_id:, filter: {}, limit: 50, cursor: nil)
133
+ offset = cursor ? cursor.to_i : 0
134
+ rows = @mutex.synchronize do
135
+ @jobs.values
136
+ .select { |r| r.principal_id == principal_id }
137
+ .select { |r| filter['status'].nil? || filter['status'].include?(r.status) }
138
+ .select { |r| filter['agent'].nil? || r.agent.start_with?(filter['agent']) }
139
+ .sort_by(&:created_at)
140
+ end
141
+
142
+ page = rows[offset, limit] || []
143
+ next_cursor = (offset + page.size) < rows.size ? (offset + page.size).to_s : nil
144
+
145
+ summaries = page.map do |r|
146
+ lease = @leases.get(r.job_id)
147
+ counter = @leases.counter(r.job_id)
148
+ Arcp::Job::Summary.new(
149
+ job_id: r.job_id, agent: r.agent, status: r.status, created_at: r.created_at,
150
+ lease_expires_at: lease&.expires_at,
151
+ budget_remaining: counter ? counter.snapshot.transform_values { |v| v.to_s('F') } : nil
152
+ )
153
+ end
154
+
155
+ Arcp::Session::JobsResponse.new(
156
+ jobs: summaries.map(&:to_h), next_cursor: next_cursor
157
+ )
158
+ end
159
+
160
+ def lookup(job_id) = @mutex.synchronize { @jobs[job_id] }
161
+
162
+ def publish_event(job_id, event)
163
+ seq = @mutex.synchronize { @event_seq[job_id] += 1 }
164
+ env = Arcp::Envelope.build(
165
+ type: Arcp::MessageTypes::JOB_EVENT,
166
+ session_id: @mutex.synchronize { @jobs[job_id]&.submitter_session_id || '' },
167
+ job_id: job_id, event_seq: seq, payload: event.to_h
168
+ )
169
+ @event_log.append(env.session_id, env)
170
+ @subs.fanout(job_id, env)
171
+ seq
172
+ end
173
+
174
+ def publish_result(job_id, result)
175
+ record = @mutex.synchronize do
176
+ @jobs[job_id] = @jobs[job_id].with(status: 'succeeded') if @jobs[job_id]
177
+ @jobs[job_id]
178
+ end
179
+ env = Arcp::Envelope.build(
180
+ type: Arcp::MessageTypes::JOB_RESULT,
181
+ session_id: record&.submitter_session_id || '',
182
+ job_id: job_id, payload: result.to_h
183
+ )
184
+ @event_log.append(env.session_id, env)
185
+ @subs.fanout(job_id, env)
186
+ @subs.clear(job_id)
187
+ @runtime.credential_registry&.revoke_all(job_id: job_id)
188
+ @leases.revoke(job_id)
189
+ end
190
+
191
+ def publish_error(job_id, error)
192
+ record = @mutex.synchronize do
193
+ @jobs[job_id] = @jobs[job_id].with(status: error.final_status) if @jobs[job_id]
194
+ @jobs[job_id]
195
+ end
196
+ env = Arcp::Envelope.build(
197
+ type: Arcp::MessageTypes::JOB_ERROR,
198
+ session_id: record&.submitter_session_id || '',
199
+ job_id: job_id, payload: error.to_h
200
+ )
201
+ @event_log.append(env.session_id, env)
202
+ @subs.fanout(job_id, env)
203
+ @subs.clear(job_id)
204
+ @runtime.credential_registry&.revoke_all(job_id: job_id)
205
+ @leases.revoke(job_id)
206
+ end
207
+
208
+ private
209
+
210
+ def issue_credentials(job_id:, lease:, agent:, principal_id:)
211
+ return nil unless @runtime.credential_registry
212
+
213
+ @runtime.credential_registry.issue_for(
214
+ job_id: job_id, lease: lease, agent: agent, principal_id: principal_id
215
+ )
216
+ end
217
+
218
+ def build_lease(submit, job_id)
219
+ return nil unless submit.lease_request
220
+
221
+ Arcp::Lease::Lease.new(
222
+ id: "lse_#{job_id}",
223
+ capabilities: submit.lease_request.capabilities,
224
+ budget: submit.lease_request.budget,
225
+ model_use: submit.lease_request.model_use,
226
+ expires_at: submit.lease_constraints&.expires_at || submit.lease_request.expires_at,
227
+ issued_at: @clock.now.iso8601
228
+ )
229
+ end
230
+
231
+ def run_agent(task, reg, job_id, submit, lease)
232
+ ctx = JobContext.new(
233
+ job_id: job_id, agent: reg.name, input: submit.input,
234
+ lease: lease, sink: self
235
+ )
236
+ if submit.max_runtime_sec
237
+ task.async do
238
+ task.sleep(submit.max_runtime_sec)
239
+ ctx.fail!(code: 'TIMEOUT', message: 'max_runtime_sec elapsed', retryable: true)
240
+ task.stop
241
+ end
242
+ end
243
+
244
+ reg.handler.call(ctx)
245
+ ctx.finish unless ctx.instance_variable_get(:@done)
246
+ rescue Arcp::Error => e
247
+ ctx&.fail!(code: e.code, message: e.message, retryable: e.retryable?, details: e.details || {})
248
+ rescue Async::Stop
249
+ nil
250
+ rescue StandardError => e
251
+ ctx&.fail!(code: 'INTERNAL_ERROR', message: e.message, retryable: true,
252
+ details: { 'class' => e.class.name })
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arcp
4
+ module Runtime
5
+ # Tracks per-job leases and bound budget counters. The runtime asks
6
+ # `#check!(job_id, capability:)` before every authority op.
7
+ class LeaseManager
8
+ def initialize(clock: Arcp::SystemClock.new, enforce_model_use: false)
9
+ @clock = clock
10
+ @enforce_model_use = enforce_model_use
11
+ @leases = {}
12
+ @counters = {}
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def register(job_id, lease)
17
+ @mutex.synchronize do
18
+ @leases[job_id] = lease
19
+ @counters[job_id] = Arcp::Lease::BudgetCounter.new(initial: lease.budget&.per_currency&.dup || {})
20
+ end
21
+ lease
22
+ end
23
+
24
+ def get(job_id) = @mutex.synchronize { @leases[job_id] }
25
+ def counter(job_id) = @mutex.synchronize { @counters[job_id] }
26
+
27
+ def check!(job_id, capability:)
28
+ lease = get(job_id)
29
+ return if lease.nil?
30
+
31
+ if lease.expired?(@clock.now)
32
+ raise Arcp::Errors::LeaseExpired.new(
33
+ "lease #{lease.id} expired at #{lease.expires_at}",
34
+ details: { 'lease_id' => lease.id }
35
+ )
36
+ end
37
+
38
+ return if lease.capabilities.include?(capability)
39
+
40
+ raise Arcp::Errors::PermissionDenied.new(
41
+ "capability #{capability.inspect} not in lease #{lease.id}",
42
+ details: { 'capability' => capability, 'lease_id' => lease.id }
43
+ )
44
+ end
45
+
46
+ def check_model!(job_id, model_id:)
47
+ lease = get(job_id)
48
+ return true if lease.nil? && !@enforce_model_use
49
+
50
+ return true if lease&.model_use && Arcp::ModelPattern.match?(lease.model_use, model_id)
51
+
52
+ raise Arcp::Errors::PermissionDenied.new(
53
+ "model #{model_id.inspect} not permitted by lease",
54
+ details: { 'model' => model_id, 'lease_id' => lease&.id }
55
+ )
56
+ end
57
+
58
+ # Try to decrement the bound budget. Returns true on success, raises
59
+ # BudgetExhausted if no balance covers the amount. Straight-line —
60
+ # no scheduler-yielding calls between read and write.
61
+ def try_spend!(job_id, currency, amount)
62
+ counter = self.counter(job_id)
63
+ return true if counter.nil?
64
+ return true if counter.get(currency).zero? && !counter.remaining.key?(currency)
65
+
66
+ unless counter.try_decrement(currency, amount)
67
+ raise Arcp::Errors::BudgetExhausted.new(
68
+ "budget #{currency} exhausted",
69
+ details: { 'currency' => currency, 'remaining' => counter.get(currency).to_s('F') }
70
+ )
71
+ end
72
+ true
73
+ end
74
+
75
+ def remaining(job_id)
76
+ c = counter(job_id)
77
+ c ? c.snapshot : {}
78
+ end
79
+
80
+ def revoke(job_id)
81
+ @mutex.synchronize do
82
+ @leases.delete(job_id)
83
+ @counters.delete(job_id)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+
5
+ require_relative '../envelope'
6
+ require_relative '../session'
7
+ require_relative '../job'
8
+ require_relative '../lease'
9
+ require_relative '../credential_provisioner'
10
+ require_relative '../auth'
11
+ require_relative '../clock'
12
+ require_relative '../message_types'
13
+ require_relative 'credential_registry'
14
+
15
+ module Arcp
16
+ module Runtime
17
+ # ARCP runtime. Owns the agent registry, job manager, lease manager,
18
+ # subscription manager, and event log. Sessions attach via
19
+ # `#accept(transport)` which returns an `Async::Task` running the
20
+ # `SessionActor` for that connection.
21
+ class Runtime
22
+ attr_reader :auth_verifier, :clock, :name, :version,
23
+ :heartbeat_interval_sec, :resume_window_sec,
24
+ :job_manager, :lease_manager, :subscription_manager,
25
+ :event_log, :credential_registry, :enforce_model_use
26
+
27
+ def initialize(auth_verifier:, name: 'arcp-runtime', version: Arcp::VERSION,
28
+ heartbeat_interval_sec: 30, resume_window_sec: 300,
29
+ clock: Arcp::SystemClock.new, credential_provisioner: nil,
30
+ credential_store: nil, require_durable_store: false,
31
+ enforce_model_use: false)
32
+ if require_durable_store && credential_provisioner && credential_store.nil?
33
+ raise Arcp::Errors::InvalidRequest,
34
+ 'provisioned_credentials requires a CredentialStore'
35
+ end
36
+
37
+ @auth_verifier = auth_verifier
38
+ @name = name
39
+ @version = version
40
+ @heartbeat_interval_sec = heartbeat_interval_sec
41
+ @resume_window_sec = resume_window_sec
42
+ @clock = clock
43
+ @enforce_model_use = enforce_model_use
44
+ @credential_registry = build_credential_registry(
45
+ credential_provisioner: credential_provisioner,
46
+ credential_store: credential_store,
47
+ clock: clock
48
+ )
49
+
50
+ @event_log = EventLog.new(window_sec: resume_window_sec, clock: clock)
51
+ @lease_manager = LeaseManager.new(clock: clock, enforce_model_use: enforce_model_use)
52
+ @subscription_manager = SubscriptionManager.new
53
+ @job_manager = JobManager.new(
54
+ runtime: self,
55
+ lease_manager: @lease_manager,
56
+ subscription_manager: @subscription_manager,
57
+ event_log: @event_log,
58
+ clock: clock
59
+ )
60
+ @sessions = {}
61
+ @mutex = Mutex.new
62
+ end
63
+
64
+ def register_agent(name:, versions:, default:, handler:)
65
+ @job_manager.register_agent(name: name, versions: versions, default: default, handler: handler)
66
+ end
67
+
68
+ def local_capabilities(agents_inventory: false)
69
+ features = Arcp::Session::Feature::ALL.dup
70
+ unless @credential_registry
71
+ features -= [
72
+ Arcp::Session::Feature::MODEL_USE,
73
+ Arcp::Session::Feature::PROVISIONED_CREDENTIALS
74
+ ]
75
+ end
76
+
77
+ Arcp::Session::CapabilitySet.local(
78
+ features: features,
79
+ agents: agents_inventory ? @job_manager.agent_inventory : nil
80
+ )
81
+ end
82
+
83
+ def accept(transport)
84
+ actor = SessionActor.new(runtime: self, transport: transport)
85
+ actor.run
86
+ end
87
+
88
+ def register_session(session_id, actor)
89
+ @mutex.synchronize { @sessions[session_id] = actor }
90
+ end
91
+
92
+ def deregister_session(session_id)
93
+ @mutex.synchronize { @sessions.delete(session_id) }
94
+ end
95
+
96
+ def session(session_id) = @mutex.synchronize { @sessions[session_id] }
97
+
98
+ def shutdown(reason: nil)
99
+ actors = @mutex.synchronize { @sessions.values.dup }
100
+ actors.each { |a| a.send_envelope(bye_envelope(a.session_id, reason)) }
101
+ end
102
+
103
+ private
104
+
105
+ def build_credential_registry(credential_provisioner:, credential_store:, clock:)
106
+ return nil unless credential_provisioner
107
+
108
+ store = credential_store || Arcp::Credentials::InMemoryStore.new
109
+ CredentialRegistry.new(
110
+ provisioner: credential_provisioner,
111
+ store: store,
112
+ clock: clock
113
+ ).tap(&:reconcile_on_startup!)
114
+ end
115
+
116
+ def bye_envelope(session_id, reason)
117
+ Arcp::Envelope.build(
118
+ type: Arcp::MessageTypes::SESSION_BYE,
119
+ session_id: session_id,
120
+ payload: Arcp::Session::Bye.new(reason: reason).to_h
121
+ )
122
+ end
123
+ end
124
+ end
125
+ end