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,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arcp
4
+ module Job
5
+ Subscribe = Data.define(:job_id, :from_event_seq, :history) do
6
+ def self.from_h(h)
7
+ h = h.transform_keys(&:to_s)
8
+ new(
9
+ job_id: h.fetch('job_id'),
10
+ from_event_seq: h['from_event_seq'],
11
+ history: h.fetch('history', false)
12
+ )
13
+ end
14
+
15
+ def to_h
16
+ out = { 'job_id' => job_id, 'history' => history }
17
+ out['from_event_seq'] = from_event_seq if from_event_seq
18
+ out
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arcp
4
+ module Job
5
+ Subscribed = Data.define(:job_id, :subscribed_from) do
6
+ def self.from_h(h)
7
+ h = h.transform_keys(&:to_s)
8
+ new(job_id: h.fetch('job_id'), subscribed_from: h.fetch('subscribed_from'))
9
+ end
10
+
11
+ def to_h = { 'job_id' => job_id, 'subscribed_from' => subscribed_from }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arcp
4
+ module Job
5
+ Summary = Data.define(:job_id, :agent, :status, :created_at, :lease_expires_at, :budget_remaining) do
6
+ def self.from_h(h)
7
+ h = h.transform_keys(&:to_s)
8
+ new(
9
+ job_id: h.fetch('job_id'),
10
+ agent: h.fetch('agent'),
11
+ status: h.fetch('status'),
12
+ created_at: h['created_at'],
13
+ lease_expires_at: h['lease_expires_at'],
14
+ budget_remaining: h['budget_remaining']
15
+ )
16
+ end
17
+
18
+ def to_h
19
+ out = { 'job_id' => job_id, 'agent' => agent, 'status' => status }
20
+ out['created_at'] = created_at if created_at
21
+ out['lease_expires_at'] = lease_expires_at if lease_expires_at
22
+ out['budget_remaining'] = budget_remaining if budget_remaining
23
+ out
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arcp
4
+ module Job
5
+ Unsubscribe = Data.define(:job_id) do
6
+ def self.from_h(h) = new(job_id: h.transform_keys(&:to_s).fetch('job_id'))
7
+ def to_h = { 'job_id' => job_id }
8
+ end
9
+ end
10
+ end
data/lib/arcp/job.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lease'
4
+ require_relative 'job/agent_ref'
5
+ require_relative 'job/submit'
6
+ require_relative 'job/accepted'
7
+ require_relative 'job/event'
8
+ require_relative 'job/result'
9
+ require_relative 'job/job_error'
10
+ require_relative 'job/cancel'
11
+ require_relative 'job/subscribe'
12
+ require_relative 'job/subscribed'
13
+ require_relative 'job/unsubscribe'
14
+ require_relative 'job/handle'
15
+ require_relative 'job/summary'
data/lib/arcp/lease.rb ADDED
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+ require 'time'
5
+
6
+ require_relative 'errors'
7
+ require_relative 'credential'
8
+
9
+ module Arcp
10
+ module Lease
11
+ LeaseConstraints = Data.define(:expires_at, :max_budget) do
12
+ def self.from_h(h)
13
+ return nil if h.nil?
14
+
15
+ h = h.transform_keys(&:to_s)
16
+ new(expires_at: h['expires_at'], max_budget: h['max_budget'])
17
+ end
18
+
19
+ def to_h
20
+ out = {}
21
+ out['expires_at'] = expires_at if expires_at
22
+ out['max_budget'] = max_budget if max_budget
23
+ out
24
+ end
25
+
26
+ def validate!
27
+ return if expires_at.nil?
28
+
29
+ t = Time.iso8601(expires_at)
30
+ raise Arcp::Errors::InvalidRequest, "expires_at must be UTC (use 'Z'): #{expires_at}" unless t.utc?
31
+ end
32
+ end
33
+
34
+ CostBudget = Data.define(:per_currency) do
35
+ def self.parse(entries)
36
+ h = {}
37
+ Array(entries).each do |entry|
38
+ ccy, amount = entry.to_s.split(':', 2)
39
+ if ccy.nil? || amount.nil?
40
+ raise Arcp::Errors::InvalidRequest,
41
+ "malformed budget entry: #{entry.inspect}"
42
+ end
43
+
44
+ h[ccy] = BigDecimal(amount)
45
+ end
46
+ new(per_currency: h.freeze)
47
+ end
48
+
49
+ def to_a = per_currency.map { |ccy, amt| "#{ccy}:#{amt.to_s('F')}" }
50
+ def to_h = { 'cost.budget' => to_a }
51
+
52
+ def remaining(currency) = per_currency[currency] || BigDecimal('0')
53
+ def currencies = per_currency.keys
54
+ end
55
+
56
+ class BudgetCounter
57
+ attr_reader :remaining
58
+
59
+ def initialize(initial:)
60
+ @remaining = initial.dup
61
+ end
62
+
63
+ def try_decrement(currency, amount)
64
+ balance = @remaining[currency]
65
+ return false if balance.nil?
66
+ return false if balance < amount
67
+
68
+ @remaining[currency] = balance - amount
69
+ true
70
+ end
71
+
72
+ def get(currency) = @remaining[currency] || BigDecimal('0')
73
+ def negative?(currency) = (@remaining[currency] || BigDecimal('0')).negative?
74
+
75
+ def snapshot
76
+ @remaining.transform_values(&:dup).freeze
77
+ end
78
+ end
79
+
80
+ LeaseRequest = Data.define(:capabilities, :budget, :model_use, :expires_at) do
81
+ def initialize(capabilities:, budget: nil, model_use: nil, expires_at: nil)
82
+ super(
83
+ capabilities: Array(capabilities).freeze,
84
+ budget: budget,
85
+ model_use: model_use ? Array(model_use).freeze : nil,
86
+ expires_at: expires_at
87
+ )
88
+ end
89
+
90
+ def self.from_h(h)
91
+ return nil if h.nil?
92
+
93
+ h = h.transform_keys(&:to_s)
94
+ new(
95
+ capabilities: Array(h['capabilities']).freeze,
96
+ budget: h['cost.budget'] ? CostBudget.parse(h['cost.budget']) : nil,
97
+ model_use: h['model.use'] ? Array(h['model.use']).freeze : nil,
98
+ expires_at: h['expires_at']
99
+ )
100
+ end
101
+
102
+ def to_h
103
+ out = { 'capabilities' => capabilities }
104
+ out['cost.budget'] = budget.to_a if budget
105
+ out['model.use'] = model_use if model_use
106
+ out['expires_at'] = expires_at if expires_at
107
+ out
108
+ end
109
+ end
110
+
111
+ Lease = Data.define(:id, :capabilities, :budget, :model_use, :expires_at, :issued_at) do
112
+ def initialize(id:, capabilities:, issued_at:, budget: nil, model_use: nil, expires_at: nil)
113
+ super(
114
+ id: id,
115
+ capabilities: Array(capabilities).freeze,
116
+ budget: budget,
117
+ model_use: model_use ? Array(model_use).freeze : nil,
118
+ expires_at: expires_at,
119
+ issued_at: issued_at
120
+ )
121
+ end
122
+
123
+ def self.from_h(h)
124
+ h = h.transform_keys(&:to_s)
125
+ new(
126
+ id: h.fetch('id'),
127
+ capabilities: Array(h['capabilities']).freeze,
128
+ budget: h['cost.budget'] ? CostBudget.parse(h['cost.budget']) : nil,
129
+ model_use: h['model.use'] ? Array(h['model.use']).freeze : nil,
130
+ expires_at: h['expires_at'],
131
+ issued_at: h['issued_at']
132
+ )
133
+ end
134
+
135
+ def to_h
136
+ out = { 'id' => id, 'capabilities' => capabilities, 'issued_at' => issued_at }
137
+ out['cost.budget'] = budget.to_a if budget
138
+ out['model.use'] = model_use if model_use
139
+ out['expires_at'] = expires_at if expires_at
140
+ out
141
+ end
142
+
143
+ def expired?(now)
144
+ return false if expires_at.nil?
145
+
146
+ Time.iso8601(expires_at) <= now
147
+ end
148
+ end
149
+
150
+ module Subsetting
151
+ module_function
152
+
153
+ # Compute a delegate lease bounded by the parent. Raises
154
+ # `LeaseSubsetViolation` if requested capabilities exceed parent,
155
+ # requested expires_at is beyond parent, or remaining budget can't
156
+ # cover the requested amount.
157
+ def bound(parent:, request:, parent_remaining: nil)
158
+ excess = request.capabilities - parent.capabilities
159
+ unless excess.empty?
160
+ raise Arcp::Errors::LeaseSubsetViolation,
161
+ "child lease capabilities not in parent: #{excess.inspect}"
162
+ end
163
+
164
+ if request.expires_at && parent.expires_at
165
+ parent_t = Time.iso8601(parent.expires_at)
166
+ req_t = Time.iso8601(request.expires_at)
167
+ if req_t > parent_t
168
+ raise Arcp::Errors::LeaseSubsetViolation,
169
+ "child expires_at #{request.expires_at} exceeds parent #{parent.expires_at}"
170
+ end
171
+ end
172
+
173
+ budget = nil
174
+ if request.budget
175
+ parent_pc = parent_remaining || (parent.budget&.per_currency || {})
176
+ missing = request.budget.per_currency.filter_map do |ccy, amt|
177
+ available = parent_pc[ccy] || BigDecimal('0')
178
+ (ccy if amt > available)
179
+ end
180
+ unless missing.empty?
181
+ raise Arcp::Errors::LeaseSubsetViolation,
182
+ "child budget exceeds parent remaining for: #{missing.inspect}"
183
+ end
184
+
185
+ budget = request.budget
186
+ end
187
+
188
+ model_use = bound_model_use(parent: parent, request: request)
189
+
190
+ Lease.new(
191
+ id: Arcp::Ids.session_id.sub(/^ses_/, 'lse_'),
192
+ capabilities: request.capabilities,
193
+ budget: budget,
194
+ model_use: model_use,
195
+ expires_at: request.expires_at || parent.expires_at,
196
+ issued_at: Time.now.utc.iso8601
197
+ )
198
+ end
199
+
200
+ def bound_model_use(parent:, request:)
201
+ return nil unless request.model_use
202
+
203
+ unless request.model_use.all? { |pattern| Arcp::ModelPattern.implied_by?(parent.model_use, pattern) }
204
+ raise Arcp::Errors::LeaseSubsetViolation,
205
+ "child model.use expands beyond parent: #{request.model_use.inspect}"
206
+ end
207
+
208
+ request.model_use
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arcp
4
+ module MessageTypes
5
+ SESSION_HELLO = 'session.hello'
6
+ SESSION_WELCOME = 'session.welcome'
7
+ SESSION_BYE = 'session.bye'
8
+ SESSION_ERROR = 'session.error'
9
+ SESSION_PING = 'session.ping'
10
+ SESSION_PONG = 'session.pong'
11
+ SESSION_ACK = 'session.ack'
12
+ SESSION_LIST_JOBS = 'session.list_jobs'
13
+ SESSION_JOBS = 'session.jobs'
14
+
15
+ JOB_SUBMIT = 'job.submit'
16
+ JOB_ACCEPTED = 'job.accepted'
17
+ JOB_EVENT = 'job.event'
18
+ JOB_RESULT = 'job.result'
19
+ JOB_ERROR = 'job.error'
20
+ JOB_CANCEL = 'job.cancel'
21
+ JOB_SUBSCRIBE = 'job.subscribe'
22
+ JOB_SUBSCRIBED = 'job.subscribed'
23
+ JOB_UNSUBSCRIBE = 'job.unsubscribe'
24
+
25
+ ALL = [
26
+ SESSION_HELLO, SESSION_WELCOME, SESSION_BYE, SESSION_ERROR,
27
+ SESSION_PING, SESSION_PONG, SESSION_ACK,
28
+ SESSION_LIST_JOBS, SESSION_JOBS,
29
+ JOB_SUBMIT, JOB_ACCEPTED, JOB_EVENT, JOB_RESULT, JOB_ERROR,
30
+ JOB_CANCEL, JOB_SUBSCRIBE, JOB_SUBSCRIBED, JOB_UNSUBSCRIBE
31
+ ].freeze
32
+
33
+ def self.known?(type) = ALL.include?(type)
34
+ end
35
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../clock'
4
+ require_relative '../credential'
5
+ require_relative '../credential_provisioner'
6
+
7
+ module Arcp
8
+ module Runtime
9
+ class CredentialRegistry
10
+ def initialize(provisioner:, store:, clock: Arcp::SystemClock.new)
11
+ @provisioner = provisioner
12
+ @store = store
13
+ @clock = clock
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def issue_for(job_id:, lease:, agent:, principal_id:)
18
+ credentials = @provisioner.issue(
19
+ lease: lease, job_id: job_id, agent: agent, principal_id: principal_id
20
+ )
21
+ Array(credentials).each do |credential|
22
+ @store.record(job_id: job_id, credential_id: credential.id)
23
+ end
24
+ Array(credentials).freeze
25
+ end
26
+
27
+ def rotate(job_id:, credential_id:, new_value:)
28
+ revoke(credential_id)
29
+ new_id = "#{credential_id}_rotated_#{@clock.now.to_i}"
30
+ @store.record(job_id: job_id, credential_id: new_id)
31
+ new_id
32
+ end
33
+
34
+ def revoke_all(job_id:)
35
+ @store.outstanding(job_id: job_id).count do |credential_id|
36
+ revoke(credential_id).tap do |revoked|
37
+ @store.forget(job_id: job_id, credential_id: credential_id) if revoked
38
+ end
39
+ end
40
+ end
41
+
42
+ def reconcile_on_startup!
43
+ @store.all_outstanding.each do |job_id, credential_ids|
44
+ credential_ids.each do |credential_id|
45
+ @store.forget(job_id: job_id, credential_id: credential_id) if revoke(credential_id)
46
+ end
47
+ end
48
+ nil
49
+ end
50
+
51
+ private
52
+
53
+ def revoke(credential_id)
54
+ attempts = 0
55
+ begin
56
+ attempts += 1
57
+ @provisioner.revoke(credential_id: credential_id)
58
+ true
59
+ rescue StandardError
60
+ retry if attempts < 2
61
+
62
+ false
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arcp
4
+ module Runtime
5
+ # In-memory ring of buffered events keyed by session_id. The runtime
6
+ # uses this for the resume window and `session.ack`-driven early
7
+ # eviction. A SQLite-backed variant (same API) is suitable for
8
+ # multi-process runtimes; for v1 we ship the in-memory implementation
9
+ # used by tests and the Falcon-hosted single-process runtime.
10
+ class EventLog
11
+ def initialize(window_sec: 300, clock: Arcp::SystemClock.new)
12
+ @window_sec = window_sec
13
+ @clock = clock
14
+ @sessions = Hash.new { |h, k| h[k] = [] }
15
+ @floor = Hash.new(0)
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def append(session_id, envelope)
20
+ @mutex.synchronize do
21
+ @sessions[session_id] << [envelope, @clock.monotonic]
22
+ end
23
+ envelope
24
+ end
25
+
26
+ def floor(session_id) = @floor[session_id]
27
+
28
+ def evict_up_to(session_id, seq)
29
+ @mutex.synchronize do
30
+ @floor[session_id] = [@floor[session_id], seq].max
31
+ @sessions[session_id].reject! do |env, _t|
32
+ env.event_seq && env.event_seq <= seq
33
+ end
34
+ end
35
+ end
36
+
37
+ def replay(session_id, from_event_seq: nil)
38
+ @mutex.synchronize do
39
+ @sessions[session_id].each_with_object([]) do |(env, _t), out|
40
+ next if env.event_seq.nil?
41
+ next if from_event_seq && env.event_seq < from_event_seq
42
+
43
+ out << env
44
+ end
45
+ end
46
+ end
47
+
48
+ # Evict events past the resume window (advisory; consumer drives via timer).
49
+ def expire!
50
+ now = @clock.monotonic
51
+ @mutex.synchronize do
52
+ @sessions.each_value do |buf|
53
+ buf.reject! { |(_e, t)| (now - t) > @window_sec }
54
+ end
55
+ end
56
+ end
57
+
58
+ # @api private
59
+ def buffer_size(session_id) = @sessions[session_id].size
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async/queue'
4
+ require 'base64'
5
+ require 'time'
6
+
7
+ module Arcp
8
+ module Runtime
9
+ # Passed to an agent handler. Exposes emission seams (events,
10
+ # progress, tool calls, streamed result) and read-only state
11
+ # (job_id, agent, input, lease).
12
+ class JobContext
13
+ attr_reader :job_id, :agent, :input, :lease, :event_seq
14
+
15
+ def initialize(job_id:, agent:, input:, lease:, sink:)
16
+ @job_id = job_id
17
+ @agent = agent
18
+ @input = input
19
+ @lease = lease
20
+ @sink = sink
21
+ @event_seq = 0
22
+ @result_id = nil
23
+ @result_buffer = []
24
+ @done = false
25
+ @chunked = false
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ def emit(kind:, body:)
30
+ event = Arcp::Job::Event.new(kind: kind, body: body)
31
+ @event_seq = @sink.publish_event(@job_id, event)
32
+ event
33
+ end
34
+
35
+ def log(level:, message:, **fields)
36
+ emit(kind: Arcp::Job::EventKind::LOG,
37
+ body: Arcp::Job::EventBody::Log.new(level: level, message: message, fields: fields))
38
+ end
39
+
40
+ def progress(current:, total: nil, units: nil, message: nil)
41
+ emit(kind: Arcp::Job::EventKind::PROGRESS,
42
+ body: Arcp::Job::EventBody::Progress.new(current: current, total: total, units: units,
43
+ message: message))
44
+ end
45
+
46
+ def metric(name:, value:, unit: nil)
47
+ emit(kind: Arcp::Job::EventKind::METRIC,
48
+ body: Arcp::Job::EventBody::Metric.new(name: name, value: value, unit: unit))
49
+ end
50
+
51
+ def status(phase:, message: nil, fields: {})
52
+ emit(kind: Arcp::Job::EventKind::STATUS,
53
+ body: Arcp::Job::EventBody::Status.new(phase: phase, message: message,
54
+ fields: fields))
55
+ end
56
+
57
+ def rotate_credential(id:, new_value:)
58
+ new_id = @sink.runtime.credential_registry&.rotate(
59
+ job_id: job_id,
60
+ credential_id: id,
61
+ new_value: new_value
62
+ )
63
+ status(
64
+ phase: 'credential_rotated',
65
+ fields: { 'id' => new_id || id, 'value' => new_value }
66
+ )
67
+ end
68
+
69
+ def tool_call(call_id:, tool:, args:)
70
+ emit(kind: Arcp::Job::EventKind::TOOL_CALL,
71
+ body: Arcp::Job::EventBody::ToolCall.new(call_id: call_id, tool: tool, args: args))
72
+ end
73
+
74
+ def tool_result(call_id:, result: nil, error: nil)
75
+ emit(kind: Arcp::Job::EventKind::TOOL_RESULT,
76
+ body: Arcp::Job::EventBody::ToolResult.new(call_id: call_id, result: result, error: error))
77
+ end
78
+
79
+ def stream_result(encoding: 'utf8', &block)
80
+ raise Arcp::Errors::ProtocolViolation, 'result already finalized' if @done
81
+
82
+ @chunked = true
83
+ @result_id = Arcp::Ids.result_id
84
+
85
+ writer = ChunkWriter.new(ctx: self, encoding: encoding, result_id: @result_id)
86
+ if block
87
+ yield writer
88
+ writer.close
89
+ @result_buffer = writer.totals
90
+ @result_buffer
91
+ else
92
+ writer
93
+ end
94
+ end
95
+
96
+ def finish(result: nil)
97
+ raise Arcp::Errors::ProtocolViolation, 'result already finalized' if @done
98
+
99
+ if @chunked && !result.nil?
100
+ raise Arcp::Errors::ProtocolViolation, 'cannot mix inline result with result_chunk stream'
101
+ end
102
+
103
+ @done = true
104
+
105
+ @sink.publish_result(
106
+ @job_id,
107
+ Arcp::Job::Result.new(
108
+ job_id: @job_id, final_status: 'success',
109
+ result: result,
110
+ result_id: @chunked ? @result_id : nil,
111
+ result_size: @chunked ? @result_buffer[:bytes] : nil,
112
+ completed_at: Time.now.utc.iso8601
113
+ )
114
+ )
115
+ end
116
+
117
+ def fail!(code:, message: nil, retryable: false, details: {})
118
+ raise Arcp::Errors::ProtocolViolation, 'result already finalized' if @done
119
+
120
+ @done = true
121
+ @sink.publish_error(
122
+ @job_id,
123
+ Arcp::Job::JobError.new(
124
+ job_id: @job_id, final_status: 'error',
125
+ code: code, message: message, retryable: retryable, details: details
126
+ )
127
+ )
128
+ end
129
+
130
+ # @api private
131
+ class ChunkWriter
132
+ def initialize(ctx:, encoding:, result_id:)
133
+ @ctx = ctx
134
+ @encoding = encoding
135
+ @result_id = result_id
136
+ @seq = 0
137
+ @bytes = 0
138
+ @closed = false
139
+ end
140
+
141
+ def write(chunk, more: true)
142
+ raise Arcp::Errors::ProtocolViolation, 'stream closed' if @closed
143
+
144
+ data = case @encoding
145
+ when 'base64' then Base64.strict_encode64(chunk)
146
+ else chunk.dup.force_encoding('UTF-8')
147
+ end
148
+ @bytes += chunk.bytesize
149
+ body = Arcp::Job::EventBody::ResultChunk.new(
150
+ result_id: @result_id, chunk_seq: @seq, data: data,
151
+ encoding: @encoding, more: more
152
+ )
153
+ @ctx.emit(kind: Arcp::Job::EventKind::RESULT_CHUNK, body: body)
154
+ @seq += 1
155
+ end
156
+
157
+ def close
158
+ return if @closed
159
+
160
+ @closed = true
161
+ end
162
+
163
+ def totals = { bytes: @bytes, chunks: @seq, result_id: @result_id }
164
+ end
165
+ end
166
+ end
167
+ end