nitro_intelligence 3.1.0 → 4.1.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +9 -0
  3. data/Rakefile +21 -1
  4. data/docs/ASSISTANTS.md +594 -0
  5. data/docs/CHANGELOG.md +243 -0
  6. data/docs/README.md +190 -12
  7. data/lib/nitro_intelligence/assistant.rb +82 -3
  8. data/lib/nitro_intelligence/assistant_registry.rb +18 -0
  9. data/lib/nitro_intelligence/assistants.rb +340 -102
  10. data/lib/nitro_intelligence/client/base.rb +88 -8
  11. data/lib/nitro_intelligence/client/client.rb +22 -2
  12. data/lib/nitro_intelligence/client/factory.rb +12 -3
  13. data/lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb +3 -1
  14. data/lib/nitro_intelligence/client/handlers/base_handler.rb +17 -2
  15. data/lib/nitro_intelligence/client/handlers/chat_handler.rb +3 -1
  16. data/lib/nitro_intelligence/client/handlers/decision_handler.rb +75 -0
  17. data/lib/nitro_intelligence/client/handlers/image_handler.rb +6 -2
  18. data/lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb +30 -12
  19. data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +16 -3
  20. data/lib/nitro_intelligence/client/handlers/observed/decision_handler.rb +72 -0
  21. data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +20 -5
  22. data/lib/nitro_intelligence/client/handlers/observed/text_to_speech_handler.rb +27 -9
  23. data/lib/nitro_intelligence/client/handlers/text_to_speech_handler.rb +3 -1
  24. data/lib/nitro_intelligence/client/observed.rb +8 -0
  25. data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +35 -7
  26. data/lib/nitro_intelligence/configuration.rb +48 -0
  27. data/lib/nitro_intelligence/decision.rb +105 -0
  28. data/lib/nitro_intelligence/deprecator.rb +10 -0
  29. data/lib/nitro_intelligence/langfuse_extension.rb +1 -0
  30. data/lib/nitro_intelligence/langfuse_tracer_provider.rb +7 -6
  31. data/lib/nitro_intelligence/media/audio.rb +6 -0
  32. data/lib/nitro_intelligence/media/image.rb +14 -1
  33. data/lib/nitro_intelligence/media/image_generation.rb +14 -2
  34. data/lib/nitro_intelligence/media/media.rb +4 -0
  35. data/lib/nitro_intelligence/models/model.rb +7 -0
  36. data/lib/nitro_intelligence/models/model_catalog.rb +3 -0
  37. data/lib/nitro_intelligence/models/model_factory.rb +2 -0
  38. data/lib/nitro_intelligence/null_cache.rb +2 -0
  39. data/lib/nitro_intelligence/observability/project.rb +41 -2
  40. data/lib/nitro_intelligence/observability/project_client.rb +10 -1
  41. data/lib/nitro_intelligence/observability/project_client_registry.rb +18 -0
  42. data/lib/nitro_intelligence/observability/prompt.rb +4 -3
  43. data/lib/nitro_intelligence/observability/prompt_resolver.rb +2 -0
  44. data/lib/nitro_intelligence/observability/prompt_store.rb +9 -0
  45. data/lib/nitro_intelligence/observability/upload_handler.rb +3 -1
  46. data/lib/nitro_intelligence/pending_review.rb +257 -0
  47. data/lib/nitro_intelligence/railtie.rb +10 -0
  48. data/lib/nitro_intelligence/reporter.rb +29 -0
  49. data/lib/nitro_intelligence/review_decision.rb +36 -0
  50. data/lib/nitro_intelligence/run_result.rb +160 -0
  51. data/lib/nitro_intelligence/tool_call_review_interrupt.rb +9 -13
  52. data/lib/nitro_intelligence/tool_call_review_validator.rb +6 -8
  53. data/lib/nitro_intelligence/trace.rb +8 -0
  54. data/lib/nitro_intelligence/version.rb +2 -1
  55. data/lib/nitro_intelligence.rb +40 -0
  56. metadata +12 -1
@@ -1,29 +1,64 @@
1
1
  require "json"
2
2
  require "net/http"
3
3
  require "uri"
4
- require "nitro_intelligence/tool_call_review_interrupt"
5
- require "nitro_intelligence/tool_call_review_validator"
4
+ require "nitro_intelligence/deprecator"
5
+ require "nitro_intelligence/pending_review"
6
+ require "nitro_intelligence/run_result"
6
7
 
7
8
  module NitroIntelligence
9
+ # The client for Nitro Intelligence Assistants: runs assistants on threads, and resumes runs
10
+ # paused for review. Most hosts reach it through {NitroIntelligence.assistants}, or through an
11
+ # {Assistant}, which supplies `assistant_id` itself. See `docs/ASSISTANTS.md`.
8
12
  class Assistants
13
+ # Raised by {#initialize} without an `api_key` or `user_id`.
9
14
  class ConfigurationError < StandardError; end
15
+ # Raised by {#run} when Assistants refuses to create or seed the thread, or the assistant has no
16
+ # graph. The message is the response body.
10
17
  class ThreadInitializationError < StandardError; end
18
+ # Raised by {#run} when Assistants rejects the run, and when the run itself fails. The message
19
+ # carries the failure Assistants reported.
11
20
  class RunError < StandardError; end
21
+ # Raised when resuming a thread fails: the thread is not paused on what the review answers, a
22
+ # decision is one the pause does not allow, or Assistants refuses the resume.
12
23
  class ThreadResumptionError < StandardError; end
24
+ # Raised by {#thread_state}, {#thread_messages} and {#under_review} when the thread's state cannot
25
+ # be read. The message is the response body.
13
26
  class ThreadStateError < StandardError; end
27
+ # The review being answered is no longer the one the thread is paused on. A subclass, so that
28
+ # callers rescuing ThreadResumptionError still catch it.
29
+ class ReviewSupersededError < ThreadResumptionError; end
14
30
 
31
+ # @api private
32
+ # What Assistants answers, with a 400, to a resume of a thread that is no longer paused.
33
+ NOT_INTERRUPTED_REFUSAL = "thread is not in interrupted state".freeze
34
+
35
+ # @api private
15
36
  # Assistants answers with a conflict when `ifExists: "raise"` is sent for a thread that already exists.
16
37
  THREAD_CONFLICT_CODE = 409
17
38
 
39
+ # @api private
40
+ # Where a `/runs/wait` response says which run it waited on.
41
+ RUN_LOCATION = %r{/runs/([^/]+)\z}
42
+
43
+ # @api private
18
44
  # Every assistant this gem is built for is served by the same deployment, so a client told
19
45
  # nothing about where to reach one gets that deployment. A host talking to a different one
20
46
  # -- a review environment, a local server -- still says so.
21
47
  DEFAULT_BASE_URL = "https://assistants.powerhome.ai".freeze
22
48
 
49
+ # The user threads are created for when a client is given none.
23
50
  DEFAULT_USER_ID = "default-user".freeze
24
51
 
25
- attr_reader :base_url, :user_id
52
+ # @return [String] the Assistants deployment this client calls
53
+ attr_reader :base_url
54
+ # @return [String] the user threads are created for
55
+ attr_reader :user_id
26
56
 
57
+ # @param api_key [String] the key Assistants authenticates the client with
58
+ # @param base_url [String, nil] the Assistants deployment to call. Defaults to the shared one.
59
+ # @param user_id [String] the user threads are created for
60
+ # @raise [ConfigurationError] when `api_key` or `user_id` is blank. Omitting `api_key` raises
61
+ # Ruby's `ArgumentError` for a missing keyword instead.
27
62
  def initialize(api_key:, base_url: nil, user_id: DEFAULT_USER_ID)
28
63
  raise ConfigurationError, "api_key is required" if api_key.blank?
29
64
  raise ConfigurationError, "user_id is required" if user_id.blank?
@@ -31,11 +66,22 @@ module NitroIntelligence
31
66
  @base_url = base_url.presence || DEFAULT_BASE_URL
32
67
  @api_key = api_key
33
68
  @user_id = user_id
34
- @tool_call_review_validator = ToolCallReviewValidator.new
35
69
  @graph_ids = {}
36
70
  end
37
71
 
38
- def await_run(thread_id:, assistant_id:, messages:, context: {})
72
+ # Runs the last of `messages` on the thread, seeding a new thread with the ones before it, and
73
+ # waits for the run to end or pause. Returns a RunResult: its `reply` is nil whenever the run
74
+ # paused, and its `review` says what the run is waiting on.
75
+ #
76
+ # @param thread_id [String] the thread, created on its first run
77
+ # @param assistant_id [String] the assistant to run
78
+ # @param messages [Array<Hash>] the conversation, oldest first: the last is run, and the ones
79
+ # before it seed a new thread
80
+ # @param context [Hash] the run's context, such as the variables its prompt needs
81
+ # @return [RunResult]
82
+ # @raise [RunError] when `messages` is empty, Assistants rejects the run, or the run fails
83
+ # @raise [ThreadInitializationError] when a new thread cannot be created or seeded
84
+ def run(thread_id:, assistant_id:, messages:, context: {})
39
85
  raise RunError, "messages cannot be empty" if messages.blank?
40
86
 
41
87
  initial_state = messages[0..-2]
@@ -45,19 +91,57 @@ module NitroIntelligence
45
91
  trigger_run(thread_id:, assistant_id:, context:, last_message:)
46
92
  end
47
93
 
94
+ # The run's reply, or nil when it paused for review or ended without one.
95
+ #
96
+ # @deprecated Use {#run}`(...).reply`, which also says why there is no reply, and how the
97
+ # platform delivered the one there is. Removed in 5.0.
98
+ # @param (see #run)
99
+ # @return [String, nil]
100
+ # @raise (see #run)
101
+ def await_run(thread_id:, assistant_id:, messages:, context: {})
102
+ NitroIntelligence.deprecator.warn(
103
+ "#await_run is deprecated and is removed in 5.0; use #run(...).reply, and read #run's " \
104
+ "interrupted? and review for a run paused for review"
105
+ )
106
+ run(thread_id:, assistant_id:, messages:, context:).reply
107
+ end
108
+
48
109
  # The thread's state as Assistants reports it, unformatted. Callers that only want the
49
110
  # conversation should reach for #thread_messages instead.
111
+ #
112
+ # @param thread_id [String]
113
+ # @return [Hash] the `GET /threads/{id}/state` response
114
+ # @raise [ThreadStateError] when the state cannot be read
50
115
  def thread_state(thread_id:)
51
116
  get_thread_state(thread_id:, error: ThreadStateError)
52
117
  end
53
118
 
54
119
  # The thread's messages as Assistants reports them, unformatted, oldest first. Each message
55
120
  # carries its own `type` ("human", "ai", "tool", ...), which callers map to their own roles.
121
+ #
122
+ # @param thread_id [String]
123
+ # @return [Array<Hash>]
124
+ # @raise [ThreadStateError] when the state cannot be read
56
125
  def thread_messages(thread_id:)
57
126
  messages_in(thread_state(thread_id:))
58
127
  end
59
128
 
129
+ # Every tool call in the thread that has no result yet, whether or not it is held for review,
130
+ # with the id of the message before it.
131
+ #
132
+ # nitro-web's VirtualConfirmationAgent::Client still reads this, since the VCA's interrupt is not
133
+ # in the platform's shape; it goes when the VCA moves onto the platform.
134
+ #
135
+ # @deprecated A reviewer decides only the calls the interrupt holds, which {#under_review}
136
+ # reports. Removed in 5.0.
137
+ # @param thread_id [String]
138
+ # @return [Array<Hash>] each with `previous_message_id`, `id`, `name` and `args`
139
+ # @raise [ThreadResumptionError] when the state cannot be read
60
140
  def tool_calls_pending_review(thread_id:)
141
+ NitroIntelligence.deprecator.warn(
142
+ "#tool_calls_pending_review is deprecated and is removed in 5.0; use " \
143
+ "#under_review(thread_id:)&.tool_calls for the tool calls awaiting a reviewer's decision"
144
+ )
61
145
  thread_state = get_thread_state(thread_id:)
62
146
  messages = messages_in(thread_state)
63
147
  reviewed_tool_call_ids = tool_messages(messages).map { |message| message["tool_call_id"] }
@@ -83,37 +167,134 @@ module NitroIntelligence
83
167
  # A tool the assistant is not configured to interrupt on runs without review, so an AI message
84
168
  # can mix calls under review with calls that are only waiting to be executed. This reports the
85
169
  # former; #tool_calls_pending_review reports both.
170
+ #
171
+ # Raises ThreadResumptionError when the state cannot be read, as it always has; #under_review is
172
+ # the plain read.
173
+ #
174
+ # @deprecated Use {#under_review}`(thread_id:)&.tool_calls.to_a`, which reports a pause on the
175
+ # reply as well. Removed in 5.0.
176
+ # @param thread_id [String]
177
+ # @return [Array<Hash>] each with `previous_message_id`, `id`, `name`, `args` and
178
+ # `allowed_decisions`
179
+ # @raise [ThreadResumptionError] when the state cannot be read
86
180
  def tool_calls_under_review(thread_id:)
87
- ToolCallReviewInterrupt.new(get_thread_state(thread_id:)).tool_calls
181
+ NitroIntelligence.deprecator.warn(
182
+ "#tool_calls_under_review is deprecated and is removed in 5.0; use " \
183
+ "#under_review(thread_id:)&.tool_calls.to_a"
184
+ )
185
+ PendingReview.from_thread_state(get_thread_state(thread_id:))&.tool_calls || []
88
186
  end
89
187
 
90
- # Resumes an interrupted thread with one review per tool call the interrupt is holding. Each
91
- # review is keyed by tool call id and names an `action` -- `approve`, `edit`, `reject` or
92
- # `respond` -- from the decisions the interrupt allows for that tool. `edit` carries `args`,
93
- # merged over the arguments the model asked for; `respond` carries the `message` returned to the
94
- # model as the tool's result; `reject` may carry a `message` explaining the refusal.
188
+ # What the thread is paused on -- its tool calls or its reply -- or nil when it is not waiting
189
+ # on a review.
190
+ #
191
+ # @param thread_id [String]
192
+ # @return [PendingReview, nil]
193
+ # @raise [ThreadStateError] when the state cannot be read
194
+ def under_review(thread_id:)
195
+ PendingReview.from_thread_state(get_thread_state(thread_id:, error: ThreadStateError))
196
+ end
197
+
198
+ # Resumes a paused thread with a reviewer's decision on whatever it is paused on, and returns the
199
+ # RunResult of the resumed run. Pass exactly one of:
200
+ #
201
+ # - `reply:`, a single review of the held draft: `{"action" => "approve"}`,
202
+ # `{"action" => "edit", "content" => "..."}` or `{"action" => "reject", "message" => "reason"}`;
203
+ # - `tool_calls:`, one review per held tool call, as #review_tool_calls takes them.
95
204
  #
96
- # Assistants records nothing about who reviewed a tool call, and the resume payload it accepts
97
- # has nowhere to carry it, so there is no reviewer argument to pass.
98
- def review_tool_calls(thread_id:, assistant_id:, tool_calls:, context: {})
205
+ # `reviewer_id` and `reviewed_at` (now, unless given) are sent in the resume run's `config.metadata`,
206
+ # where the platform reads them; it records them and never checks them.
207
+ #
208
+ # Pass the `interrupt_id` the reviewer was shown (`PendingReview#interrupt_id`): a thread no longer
209
+ # paused on it raises ReviewSupersededError instead of applying the decision to whatever the thread
210
+ # waits on now. It is required for a reply, since a new message can replace the draft the
211
+ # reviewer read with another, which an approval without it would decide -- and, with a delivery
212
+ # tool, send unread. For tool calls it is optional. The resume is keyed by the interrupt read
213
+ # here, so it never decides a pause that appeared since; reporting such a resume as
214
+ # ReviewSupersededError is best-effort (see PendingReview#unapplied_resume?), as is reporting one
215
+ # Assistants refuses because another review resumed the thread first.
216
+ #
217
+ # @param thread_id [String]
218
+ # @param assistant_id [String] the assistant the resumed run runs
219
+ # @param reviewer_id [String] who decided, as the host identifies them
220
+ # @param reply [Hash, nil] the review of a held reply
221
+ # @param tool_calls [Array<Hash>, Hash, nil] the reviews of held tool calls: a list of reviews
222
+ # that each carry their tool call's `id`, or a hash of reviews keyed by tool call id
223
+ # @param interrupt_id [String, nil] the pause the reviewer was shown; required for a reply
224
+ # @param reviewed_at [Time, String, nil] when they decided. Defaults to now.
225
+ # @param context [Hash] the resumed run's context, as for {#run}
226
+ # @return [RunResult] the resumed run
227
+ # @raise [ArgumentError] without exactly one of `reply` and `tool_calls`, without a `reviewer_id`,
228
+ # or reviewing a reply without an `interrupt_id`
229
+ # @raise [ReviewSupersededError] when the thread is no longer paused on the review answered
230
+ # @raise [ThreadResumptionError] when a decision is not one the pause allows, the thread is paused
231
+ # on the other kind of review, or the resume fails
232
+ # @example
233
+ # assistants.review(thread_id:, assistant_id:, reviewer_id: "jane@example.com",
234
+ # interrupt_id: pending.interrupt_id, reply: { "action" => "approve" })
235
+ def review(thread_id:, assistant_id:, reviewer_id:, reply: nil, tool_calls: nil, interrupt_id: nil, # rubocop:disable Metrics/ParameterLists
236
+ reviewed_at: nil, context: {})
237
+ kind, reviews = review_subject!(reply:, tool_calls:)
238
+ raise ArgumentError, "reviewer_id is required" if reviewer_id.blank?
239
+
240
+ if kind == :reply && interrupt_id.blank?
241
+ raise ArgumentError, "interrupt_id is required to review a reply: pass the interrupt_id of the " \
242
+ "review the reviewer was shown (PendingReview#interrupt_id)"
243
+ end
244
+
245
+ pending_review = pending_review!(thread_id:, kind:, interrupt_id:)
246
+ stamp = reviewer_stamp(reviewer_id, reviewed_at)
247
+
248
+ RunResult.new(resume_review(thread_id:, assistant_id:, pending_review:, reviews:, stamp:, context:))
249
+ rescue ThreadResumptionError => e
250
+ raise unless e.message.include?(NOT_INTERRUPTED_REFUSAL)
251
+
252
+ # Assistants refuses a resume once the thread is no longer paused, which is how the first of
253
+ # two reviews deciding at once wins. The second is superseded.
254
+ superseded!(thread_id, pending_review&.interrupt_id)
255
+ end
256
+
257
+ # Resumes an interrupted thread with one review per tool call the interrupt is holding: a hash
258
+ # keyed by tool call id, or a list of reviews that each carry their tool call's `id`. Each names
259
+ # an `action` -- `approve`, `edit`, `reject` or `respond` -- from the decisions the interrupt
260
+ # allows for that tool. `edit` carries `args`, merged over the arguments the model asked for;
261
+ # `respond` carries the `message` returned to the model as the tool's result; `reject` may carry
262
+ # a `message`, recorded as the reason.
263
+ #
264
+ # A thin wrapper over what #review sends for `tool_calls:`: keyed by the interrupt it read, with
265
+ # `reviewer_id` and `reviewed_at` in `config.metadata` when a reviewer is given, and raising
266
+ # ReviewSupersededError when the resumed run shows the decisions were not applied. Unlike #review,
267
+ # a resume Assistants refuses because the thread is no longer paused is raised as it always was, a
268
+ # ThreadResumptionError carrying the response body, which nitro-web parses. Returns nil.
269
+ #
270
+ # @deprecated Use {#review}`(tool_calls:, reviewer_id:, interrupt_id:)`, which requires the
271
+ # reviewer and returns the resumed run. Removed in 5.0.
272
+ # @param thread_id [String]
273
+ # @param assistant_id [String]
274
+ # @param tool_calls [Hash, Array<Hash>] the reviews
275
+ # @param context [Hash]
276
+ # @param reviewer_id [String, nil]
277
+ # @param reviewed_at [Time, String, nil]
278
+ # @return [nil]
279
+ # @raise [ReviewSupersededError] when the resumed run shows the decisions were not applied
280
+ # @raise [ThreadResumptionError] when the thread is not paused on tool calls, a decision is not one
281
+ # the pause allows, or the resume fails
282
+ def review_tool_calls(thread_id:, assistant_id:, tool_calls:, context: {}, reviewer_id: nil, reviewed_at: nil)
283
+ NitroIntelligence.deprecator.warn(
284
+ "#review_tool_calls is deprecated and is removed in 5.0; use " \
285
+ "#review(tool_calls:, reviewer_id:, interrupt_id:), which returns the resumed run's RunResult"
286
+ )
99
287
  thread = get_thread(thread_id:)
100
288
  raise ThreadResumptionError, "Thread #{thread_id} is not in the interrupted state" unless interrupted?(thread)
101
289
 
102
- interrupt = ToolCallReviewInterrupt.new(get_thread_state(thread_id:))
103
- tool_calls_under_review = interrupt.tool_calls
290
+ pending_review = PendingReview.from_thread_state(get_thread_state(thread_id:))
104
291
 
105
- if tool_calls_under_review.empty?
292
+ if pending_review.nil? || pending_review.tool_calls.empty?
106
293
  raise ThreadResumptionError, "Thread #{thread_id} has no tool calls awaiting review"
107
294
  end
108
295
 
109
- @tool_call_review_validator.validate!(tool_calls:, tool_calls_under_review:)
110
-
111
- resume_run(
112
- thread_id:,
113
- assistant_id:,
114
- resume: { decisions: interrupt.decisions(tool_calls) },
115
- context:
116
- )
296
+ stamp = reviewer_id.present? ? reviewer_stamp(reviewer_id, reviewed_at) : nil
297
+ resume_review(thread_id:, assistant_id:, pending_review:, reviews: tool_calls, stamp:, context:)
117
298
 
118
299
  nil
119
300
  end
@@ -123,8 +304,59 @@ module NitroIntelligence
123
304
  # `get_thread_state` and `resume_run` below. Renaming any of the four breaks that override --
124
305
  # loudly, in nitro-web's suite at bump time rather than in anything here. The subclass goes when
125
306
  # the VCA moves onto this platform, and this note with it.
307
+ #
308
+ # The four are private to callers and public to subclasses, so they are documented as protected.
126
309
  private
127
310
 
311
+ # Reads a thread. For subclasses.
312
+ #
313
+ # @!visibility protected
314
+ # @param thread_id [String]
315
+ # @return [Hash] the `GET /threads/{id}` response
316
+ # @raise [ThreadResumptionError] when the thread cannot be read. The message is the response body.
317
+ def get_thread(thread_id:)
318
+ parse!(get(path: "/threads/#{thread_id}"), ThreadResumptionError)
319
+ end
320
+
321
+ # Whether a thread, as {#get_thread} reads it, is paused. For subclasses.
322
+ #
323
+ # @!visibility protected
324
+ # @param thread [Hash]
325
+ # @return [Boolean]
326
+ def interrupted?(thread)
327
+ thread["status"] == "interrupted"
328
+ end
329
+
330
+ # Reads a thread's state. For subclasses.
331
+ #
332
+ # The review flows have always raised ThreadResumptionError when a state read fails, and consumers
333
+ # rescue it as such. A plain read resumes nothing, so #thread_state asks for ThreadStateError.
334
+ #
335
+ # @!visibility protected
336
+ # @param thread_id [String]
337
+ # @param error [Class<StandardError>] what to raise when the state cannot be read
338
+ # @return [Hash] the `GET /threads/{id}/state` response
339
+ # @raise [StandardError] `error`, with the response body as its message
340
+ def get_thread_state(thread_id:, error: ThreadResumptionError)
341
+ parse!(get(path: "/threads/#{thread_id}/state"), error)
342
+ end
343
+
344
+ # Resumes a paused thread with `resume` as its `command.resume`, sent as given, and waits for the
345
+ # run. For subclasses that build their own resume.
346
+ #
347
+ # @!visibility protected
348
+ # @param thread_id [String]
349
+ # @param assistant_id [String]
350
+ # @param resume [Hash, Array] the `command.resume`
351
+ # @param context [Hash]
352
+ # @return [Hash] the `/runs/wait` response
353
+ # @raise [ThreadResumptionError] when Assistants refuses the resume, with the response body as its
354
+ # message, or the resumed run fails
355
+ def resume_run(thread_id:, assistant_id:, resume:, context:)
356
+ wait_for_run(thread_id:, body: { assistant_id:, command: { resume: }, context: }, error: ThreadResumptionError)
357
+ .first
358
+ end
359
+
128
360
  # Assistants accepts `initial_state` on thread creation but never applies it, so a brand new thread is
129
361
  # seeded through the thread state endpoint instead. A thread that already exists is left untouched:
130
362
  # its state was seeded when it was created and has been built up by every run since.
@@ -132,7 +364,8 @@ module NitroIntelligence
132
364
  thread_response = create_thread(thread_id:, assistant_id:)
133
365
 
134
366
  return if thread_already_exists?(thread_response)
135
- raise ThreadInitializationError, thread_response.body if thread_response.code.to_i != 200
367
+
368
+ check!(thread_response, ThreadInitializationError)
136
369
  return if initial_state.blank?
137
370
 
138
371
  seed_new_thread_state(thread_id:, initial_state:)
@@ -173,11 +406,7 @@ module NitroIntelligence
173
406
  end
174
407
 
175
408
  def fetch_graph_id(assistant_id)
176
- assistant_response = get(path: "/assistants/#{assistant_id}")
177
-
178
- raise ThreadInitializationError, assistant_response.body if assistant_response.code.to_i != 200
179
-
180
- graph_id = JSON.parse(assistant_response.body)["graph_id"]
409
+ graph_id = parse!(get(path: "/assistants/#{assistant_id}"), ThreadInitializationError)["graph_id"]
181
410
 
182
411
  raise ThreadInitializationError, "Assistant #{assistant_id} has no graph_id" if graph_id.blank?
183
412
 
@@ -185,88 +414,95 @@ module NitroIntelligence
185
414
  end
186
415
 
187
416
  def seed_thread_state(thread_id:, initial_state:)
188
- state_response = post(
189
- path: "/threads/#{thread_id}/state",
190
- body: { values: { messages: initial_state } }
191
- )
192
-
193
- raise ThreadInitializationError, state_response.body if state_response.code.to_i != 200
194
-
195
- JSON.parse(state_response.body)
417
+ parse!(post(path: "/threads/#{thread_id}/state", body: { values: { messages: initial_state } }),
418
+ ThreadInitializationError)
196
419
  end
197
420
 
198
421
  def thread_already_exists?(response)
199
422
  response.code.to_i == THREAD_CONFLICT_CODE
200
423
  end
201
424
 
202
- # The review flows have always raised ThreadResumptionError when a state read fails, and consumers
203
- # rescue it as such. A plain read resumes nothing, so #thread_state asks for ThreadStateError.
204
- def get_thread_state(thread_id:, error: ThreadResumptionError)
205
- state_response = get(path: "/threads/#{thread_id}/state")
425
+ def trigger_run(thread_id:, assistant_id:, last_message:, context: {})
426
+ run, = wait_for_run(thread_id:, body: { assistant_id:, context:, input: { messages: [last_message] } },
427
+ error: RunError)
428
+ RunResult.new(run)
429
+ end
206
430
 
207
- raise error, state_response.body if state_response.code.to_i != 200
431
+ # Waits on a run of the thread, raising `error` when the request fails or the run reports an
432
+ # `__error__`. Returns the run and the id Assistants gave it, which the wait reports in its
433
+ # `Content-Location` (`/threads/{thread_id}/runs/{run_id}`); nil without one.
434
+ def wait_for_run(thread_id:, body:, error:)
435
+ response = post(path: "/threads/#{thread_id}/runs/wait", body:)
436
+ run = parse!(response, error)
437
+ raise_run_error!(run, error)
208
438
 
209
- JSON.parse(state_response.body)
439
+ [run, response["Content-Location"].to_s[RUN_LOCATION, 1]]
210
440
  end
211
441
 
212
- def get_thread(thread_id:)
213
- thread_response = get(path: "/threads/#{thread_id}")
442
+ def raise_run_error!(run, error)
443
+ failure = run["__error__"]
444
+ return if failure.blank?
214
445
 
215
- raise ThreadResumptionError, thread_response.body if thread_response.code.to_i != 200
446
+ detail = failure.is_a?(Hash) ? [failure["error"], failure["message"]].compact.join(": ") : failure.to_s
447
+ raise error, detail
448
+ end
216
449
 
217
- JSON.parse(thread_response.body)
450
+ def review_subject!(reply:, tool_calls:)
451
+ subjects = { reply:, tool_calls: }.compact
452
+ return subjects.first if subjects.one?
453
+
454
+ raise ArgumentError, "pass exactly one of reply: or tool_calls:"
218
455
  end
219
456
 
220
- def trigger_run(thread_id:, assistant_id:, last_message:, context: {})
221
- run_response = post(
222
- path: "/threads/#{thread_id}/runs/wait",
223
- body: {
224
- assistant_id:,
225
- context:,
226
- input: {
227
- messages: [last_message],
228
- },
229
- }
230
- )
457
+ # The thread has to be paused, on the interrupt the reviewer was shown when they say which, and
458
+ # on the kind of review they are answering. A thread whose run is under way again is not
459
+ # paused, whatever its state still reports.
460
+ def pending_review!(thread_id:, kind:, interrupt_id:)
461
+ pending_review = interrupted?(get_thread(thread_id:)) &&
462
+ PendingReview.from_thread_state(get_thread_state(thread_id:))
231
463
 
232
- raise RunError, run_response.body if run_response.code.to_i != 200
464
+ if !pending_review || (interrupt_id.present? && pending_review.interrupt_id != interrupt_id)
465
+ superseded!(thread_id, interrupt_id)
466
+ end
233
467
 
234
- run = JSON.parse(run_response.body)
235
- raise_run_error!(run, RunError)
468
+ if pending_review.kind != kind
469
+ raise ThreadResumptionError,
470
+ "Thread #{thread_id} is awaiting review of #{PendingReview::KIND_NAMES[pending_review.kind]}, " \
471
+ "not of #{PendingReview::KIND_NAMES[kind]}"
472
+ end
236
473
 
237
- Array(run["messages"]).last&.dig("content")
474
+ pending_review
238
475
  end
239
476
 
240
- def raise_run_error!(run, error)
241
- failure = run["__error__"]
242
- return if failure.blank?
477
+ def reviewer_stamp(reviewer_id, reviewed_at)
478
+ reviewed_at = (reviewed_at || Time.now).utc.iso8601 unless reviewed_at.is_a?(String)
243
479
 
244
- detail = failure.is_a?(Hash) ? [failure["error"], failure["message"]].compact.join(": ") : failure.to_s
245
- raise error, detail
480
+ { "reviewer_id" => reviewer_id.to_s, "reviewed_at" => reviewed_at }
246
481
  end
247
482
 
248
- def resume_run(thread_id:, assistant_id:, resume:, context:)
249
- run_response = post(
250
- path: "/threads/#{thread_id}/runs/wait",
251
- body: {
252
- assistant_id:,
253
- command: {
254
- resume:,
255
- },
256
- context:,
257
- }
258
- )
483
+ # Sends the decisions for `reviews`, validated against the pause, as the resume keyed by the
484
+ # pause's interrupt. A reviewer `stamp` goes in the run's `config.metadata`, where the platform
485
+ # reads it for replies and tool calls alike (LangChain drops extra keys in a tool decision).
486
+ # Raises ReviewSupersededError when the run shows the decisions were not applied.
487
+ def resume_review(thread_id:, assistant_id:, pending_review:, reviews:, stamp:, context:)
488
+ decisions = pending_review.decisions(reviews)
259
489
 
260
- raise ThreadResumptionError, run_response.body if run_response.code.to_i != 200
490
+ body = { assistant_id:, command: { resume: pending_review.resume(decisions) } }
491
+ body[:config] = { metadata: stamp } if stamp
492
+ body[:context] = context
261
493
 
262
- run = JSON.parse(run_response.body)
263
- raise_run_error!(run, ThreadResumptionError)
494
+ run, run_id = wait_for_run(thread_id:, body:, error: ThreadResumptionError)
495
+ return run unless pending_review.unapplied_resume?(run:, run_id:) { get_thread_state(thread_id:) }
264
496
 
265
- run
497
+ superseded!(thread_id, pending_review.interrupt_id, now_paused_on: PendingReview.from_run(run).interrupt_id)
266
498
  end
267
499
 
268
- def interrupted?(thread)
269
- thread["status"] == "interrupted"
500
+ def superseded!(thread_id, interrupt_id, now_paused_on: nil)
501
+ message = "Thread #{thread_id} is no longer paused on #{interrupt_id.presence || 'a review'}: " \
502
+ "another review already resumed it, or a new message superseded it"
503
+ message += "; it is now paused on #{now_paused_on}, and the decision was not applied" if now_paused_on
504
+
505
+ raise ReviewSupersededError, message
270
506
  end
271
507
 
272
508
  def messages_in(thread_state)
@@ -284,37 +520,39 @@ module NitroIntelligence
284
520
  end
285
521
 
286
522
  def get(path:)
287
- uri = URI("#{base_url}#{path}")
288
- http = Net::HTTP.new(uri.host, uri.port)
289
- http.use_ssl = uri.scheme == "https"
523
+ request(Net::HTTP::Get, path:)
524
+ end
290
525
 
291
- request = Net::HTTP::Get.new(uri)
292
- request_headers.each { |k, v| request[k] = v }
526
+ def delete(path:)
527
+ request(Net::HTTP::Delete, path:)
528
+ end
293
529
 
294
- http.request(request)
530
+ def post(path:, body:)
531
+ request(Net::HTTP::Post, path:, body:)
295
532
  end
296
533
 
297
- def delete(path:)
534
+ def request(verb, path:, body: nil)
298
535
  uri = URI("#{base_url}#{path}")
299
536
  http = Net::HTTP.new(uri.host, uri.port)
300
537
  http.use_ssl = uri.scheme == "https"
301
538
 
302
- request = Net::HTTP::Delete.new(uri)
539
+ request = verb.new(uri)
303
540
  request_headers.each { |k, v| request[k] = v }
541
+ request.body = body.to_json unless body.nil?
304
542
 
305
543
  http.request(request)
306
544
  end
307
545
 
308
- def post(path:, body:)
309
- uri = URI("#{base_url}#{path}")
310
- http = Net::HTTP.new(uri.host, uri.port)
311
- http.use_ssl = uri.scheme == "https"
546
+ # Anything but a 200 is a failure, raised as `error` with the response body as its message.
547
+ # Thread creation tells a conflict apart before it gets here.
548
+ def check!(response, error)
549
+ raise error, response.body if response.code.to_i != 200
312
550
 
313
- request = Net::HTTP::Post.new(uri)
314
- request_headers.each { |k, v| request[k] = v }
315
- request.body = body.to_json
551
+ response
552
+ end
316
553
 
317
- http.request(request)
554
+ def parse!(response, error)
555
+ JSON.parse(check!(response, error).body)
318
556
  end
319
557
 
320
558
  def request_headers