nitro_intelligence 3.0.2 → 4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: feb0fe8d0b4ae0d1da8564f048fa7130dc78318622a23a86163edc65d2f37dbf
4
- data.tar.gz: 122277bfc6b70b0aeb67166624458f37f50b1ed2b1a77044ade87575ea101bb1
3
+ metadata.gz: 03f41090e337bd4b4ba8f5caf040355d4b8cfca9d81ffcc9b7a8d91eec2ad64e
4
+ data.tar.gz: 0f83490e7d57c8935cdda63579126c3a4d93b22849c1f0f6015948b94ccbb6b8
5
5
  SHA512:
6
- metadata.gz: bf69e4777017afa0be7457612c42aadf434921c083b3a199e13a0682959b6579494793069e2548c592dd022ac5a96d342155549cfa5d36fdf798bd0a05bd647f
7
- data.tar.gz: 18854e89e04d994bddc0315296d5f21ad2551dd9b7d64d01efda6aadc25dbcd0858d1e8f24dacb2f2b261b2b65ab1bb3fe747956f5064ce6ef176b017e59b712
6
+ metadata.gz: f908c8f5cfdb7b2b56eb0d4316c1855b6fbdb25ac7355fb0390bbaa42f794e92f44ccc66255c9c8dfcd1b4c552ef8af554f933d3c23f6389a3ec6164b31b6c13
7
+ data.tar.gz: 55b75e6f31b185d3ade2f8939b8ecae69a41a6c54a3df7a1cc8405424b6762e8a788e3e4c4f8680626f4abe638d8027c69fc3e3a4cb2d590b2c196f7faec16f3
data/Rakefile CHANGED
@@ -1,6 +1,8 @@
1
1
  require "bundler/gem_tasks"
2
2
  require "rspec/core/rake_task"
3
+ require "rubocop/rake_task"
3
4
 
4
5
  RSpec::Core::RakeTask.new(:spec)
6
+ RuboCop::RakeTask.new
5
7
 
6
- task default: :spec
8
+ task default: %i[spec rubocop]
data/docs/README.md CHANGED
@@ -19,6 +19,8 @@ NitroIntelligence.configure do |config|
19
19
  # Inference (LLM) settings
20
20
  config.inference_api_key = "..." # API key for the inference service
21
21
  config.inference_base_url = "https://..." # Base URL for the inference service (optional; defaults to the inference gateway)
22
+ config.inference_max_retries = 0 # Retries the SDK makes itself (optional; defaults to 0 - see "Retries and Timeouts")
23
+ config.inference_timeout = 600 # Request timeout in seconds (optional; defaults to openai-ruby's)
22
24
 
23
25
  # Observability (Langfuse) settings
24
26
  config.observability_base_url = "https://..." # Base URL for the observability service (optional; defaults to Cerebro)
@@ -84,8 +86,11 @@ end
84
86
  | `cache_provider` | cache store | `NullCache` | ActiveSupport-compatible cache store |
85
87
  | `inference_api_key` | `String` | `""` | API key for the LLM inference service |
86
88
  | `inference_base_url` | `String` | `"https://inference.powerhome.ai"` | Base URL for the LLM inference service. Defaults to the shared inference gateway, so only a host talking to a different one needs to set it |
89
+ | `inference_max_retries` | `Integer` | `0` | Retries the SDK makes itself after a failed request. Off by default — see [Retries and Timeouts](#retries-and-timeouts) |
90
+ | `inference_timeout` | `Numeric` | `nil` | Request timeout in seconds. `nil` keeps openai-ruby's default of 600 |
87
91
  | `observability_base_url` | `String` | `"https://cerebro.powerhome.ai"` | Base URL for the Langfuse observability service. Defaults to Cerebro, so only a host talking to a different one needs to set it |
88
92
  | `observability_projects` | `Array<Hash>` | `[]` | Langfuse project credentials (slug, id, public_key, secret_key) |
93
+ | `observability_user_id` | `String` | `""` | User ID recorded on observed traces when a request passes no `user_id` parameter — see [Users](#users) |
89
94
  | `assistants_config` | `Hash` | `{}` | Assistants to make addressable by key. `base_url` (String, default: `"https://assistants.powerhome.ai"`) and `user_id` (String, default: `"default-user"`) are shared by every entry; `definitions` (Hash) holds one entry per assistant, keyed by what it is looked up with, each able to override a shared value. Without `definitions` the hash is read as credentials for a single `Assistants.new`, which takes the same two defaults — see [Assistants](#assistants) |
90
95
  | `model_config` | `Hash` | `{}` | Model defaults and per-model settings. Top-level keys: `default_text_model`, `default_audio_transcription_model`, `default_image_model`, `default_text_to_speech_model`, and `models` (array of per-model hashes keyed by `name` and `type`, with type-specific options like `aspect_ratios`/`resolutions` for images or `voices`/`response_formats` for TTS) |
91
96
 
@@ -121,6 +126,21 @@ client.chat(parameters: { model: "Qwen/Qwen3.8-27B", max_tokens: 1000, temperatu
121
126
 
122
127
  For a full list of supported parameters, see the [API reference here](https://developers.openai.com/api/reference/resources/completions/methods/create).
123
128
 
129
+ #### Retries and Timeouts
130
+
131
+ The client does not retry failed requests by default. openai-ruby on its own retries a `429`, `408`, `409` or `5xx` twice within about two seconds, but none of those is worth repeating that quickly from here: the inference gateway has already failed over between providers before it answers, and a rate limit or exhausted budget it passes on will not clear within a second. Each retry only adds load, and for billable work such as image generation a retry after a timeout can pay twice. A caller that wants retries decides when, typically in a job with backoff, and can tell a rate limit from an exhausted budget first (see the gateway's [error guidance](https://github.com/powerhome/nitro-intelligence/blob/main/docs/inference-gateway.md)).
132
+
133
+ To retry anyway, or to change the timeout, set them for every client in the configuration (`inference_max_retries`, `inference_timeout`), for one client with the same names, or for one request with openai-ruby's `request_options`. The narrower setting wins.
134
+
135
+ ```ruby
136
+ # For one client
137
+ client = NitroIntelligence::Client.new(inference_max_retries: 2, inference_timeout: 30)
138
+
139
+ # For one request, with any method
140
+ client.chat(message: "Why is the sky blue?", parameters: { request_options: { max_retries: 2, timeout: 30 } })
141
+ client.generate_image(message: "A bear installing a window", parameters: { request_options: { timeout: 120 } })
142
+ ```
143
+
124
144
  ### Audio Transcription
125
145
 
126
146
  Nitro Intelligence can be used to transcribe audio from a file into text.
@@ -323,7 +343,7 @@ client.chat(
323
343
 
324
344
  ### Custom Trace IDs
325
345
 
326
- To generate a deterministic trace ID in the observability platform, you can pass `trace_seed` as a parameter. This is useful when you want a stable identifier that is derived from a specific domain value (e.g., document ID). The same `trace_seed` will produce the same trace ID, making it easier to correlate multiple events.
346
+ To generate a deterministic trace ID in the observability platform, you can pass `trace_seed` as a parameter. This is useful when you want a stable identifier that is derived from a specific domain value (e.g., document ID). The same `trace_seed` will produce the same trace ID, making it easier to correlate multiple events. Each seeded call creates a root observation in that trace, including when another observation is active. With Langfuse v4 event reads, trace input and output come from the most recently ingested root observation. Calls without a seed keep their active parent observation.
327
347
 
328
348
  ```ruby
329
349
  document_id = "document-123"
@@ -336,22 +356,38 @@ client.chat(
336
356
  )
337
357
  ```
338
358
 
339
- ### Grouping Traces: Sessions and Tags
359
+ ### Grouping Traces: Users, Sessions and Tags
340
360
 
341
- `session_id` groups related traces in the observability platform, and `tags` label
342
- them for filtering. Both are optional and are only sent when you set them.
361
+ `user_id` attributes traces to a user of your application, `session_id` groups related traces, and `tags` label them for filtering. All three are optional. `session_id` and `tags` are sent only when you set them.
343
362
 
344
363
  ```ruby
345
364
  client = NitroIntelligence::Client.new(observability_project_slug: "fake-feature-project")
346
365
  client.chat(
347
366
  message: "why did this deploy fail?",
348
367
  parameters: {
368
+ user_id: current_user.id.to_s,
349
369
  session_id: "deploy-9f2c1ab",
350
370
  tags: ["deployment-failure-analyzer"],
351
371
  }
352
372
  )
353
373
  ```
354
374
 
375
+ #### Users
376
+
377
+ A trace that carries a `user_id` shows up under that user in Cerebro's [Users](https://langfuse.com/docs/observability/features/users) view, which lists each user with their trace count, token usage and cost, and links through to their traces. The trace list can also be filtered by user.
378
+
379
+ When a request passes no `user_id`, the `observability_user_id` configuration key is used instead, so a host can attribute all of its traces to one identity (the application itself, say) and override it per request for the traces that belong to a person:
380
+
381
+ ```ruby
382
+ NitroIntelligence.configure do |config|
383
+ config.observability_user_id = "nitro-web"
384
+ end
385
+ ```
386
+
387
+ Use an identifier that is stable and not itself personal data — a database ID rather than an email address or name — since it is stored on every trace and shown in Cerebro. It must be a String of at most 200 characters; langfuse-rb drops any other value with a warning, so convert numeric IDs with `to_s`.
388
+
389
+ `user_id` is recorded only in Cerebro. It is not sent to the inference gateway, so to attribute gateway spend to a user as well, also put the identifier in `metadata` (see below).
390
+
355
391
  ### Correlating With Application Logs and the Inference Gateway
356
392
 
357
393
  Every observed request is correlated across three systems automatically:
@@ -369,6 +405,8 @@ Every observed request is correlated across three systems automatically:
369
405
  * **Inference gateway → observability platform.** When a request fails, the
370
406
  gateway's own request identifier is read from the error response and recorded on
371
407
  the observation as `litellm_call_id` metadata.
408
+ * **Application users → observability platform.** Pass `user_id` to see a
409
+ user's traces, usage and cost together in Cerebro — see [Users](#users).
372
410
  * **Application logs → observability platform.** Put whatever your logs are keyed
373
411
  by into `metadata` — it lands on the trace *and* in the gateway's spend logs.
374
412
 
@@ -424,6 +462,16 @@ trace_id = NitroIntelligence::Trace.create_id(seed: document_id)
424
462
  reporter.score(name: "precision", value: 0.5, trace_id:)
425
463
  ```
426
464
 
465
+ #### Using different data types for scoring
466
+
467
+ By default, scores submitted on a trace are numeric, but you can use the `data_type` keyword to change it to `:boolean` or `:categorical`. For example:
468
+
469
+ ```ruby
470
+ reporter.score(name: "precision", value: "Partially Correct", trace_id:, data_type: :categorical)
471
+ ```
472
+
473
+ Numeric data is helpful for scores that happen on a sliding scale, like success rate or percent similarity. Boolean data is helpful to convey something that either passes or fails. Categorical data is helpful for scores that have pre-defined categories, like "Correct", "Partially Correct", and "Incorrect".
474
+
427
475
  ### Prompts
428
476
 
429
477
  #### Message Shape
@@ -552,7 +600,7 @@ client.chat(
552
600
 
553
601
  ## Nitro Intelligence Assistants
554
602
 
555
- `NitroIntelligence::Assistants` is Nitro Intelligence's lightweight SDK for working with hosted agent threads, runs, and human review flows. It is mainly used to initialize conversation threads, trigger agent runs, inspect agent tool calls pending human approval, and resume interrupted threads after human reviews.
603
+ `NitroIntelligence::Assistants` is Nitro Intelligence's lightweight SDK for working with hosted agent threads, runs, and human review flows. It is mainly used to initialize conversation threads, trigger agent runs, inspect what a paused run is waiting on (tool calls or a reply held for review), and resume paused threads after human reviews.
556
604
 
557
605
  For the full guide, see [ASSISTANTS.md](ASSISTANTS.md). For the service this SDK talks to, see the [Nitro Intelligence Assistants documentation](https://portal.powerapp.cloud/docs/default/system/nip-assistants).
558
606
 
@@ -563,14 +611,18 @@ An assistant is addressed by the key it is configured under:
563
611
  ```ruby
564
612
  assistant = NitroIntelligence.assistants["candidate-concierge"]
565
613
 
566
- assistant.await_run(thread_id: thread.id, messages: messages)
614
+ result = assistant.run(thread_id: thread.id, messages: messages)
615
+ result.reply # => nil while the reply is held for review
616
+ assistant.review(thread_id: thread.id, reviewer_id: user.email,
617
+ interrupt_id: result.review.interrupt_id, reply: { "action" => "approve" })
567
618
  assistant.thread_state(thread_id: thread.id)
568
619
  ```
569
620
 
570
- `await_run` and `review_tool_calls` supply the assistant's own id, so a caller never passes
571
- it -- passing `assistant_id:` to either raises `ArgumentError` rather than quietly sending the
572
- call to a different assistant, which is what a migrated caller carrying its old argument would
573
- otherwise do. Everything else is thread-scoped and delegates to the client untouched. Reach
621
+ `run`, `review`, and the deprecated `await_run` and `review_tool_calls` (see
622
+ [Upgrading to 4.0](ASSISTANTS.md#upgrading-to-40)), supply the assistant's own id, so a
623
+ caller never passes it -- passing `assistant_id:` to any of them raises `ArgumentError` rather
624
+ than quietly sending the call to a different assistant, which is what a migrated caller
625
+ carrying its old argument would otherwise do. Everything else is thread-scoped and delegates to the client untouched. Reach
574
626
  the client directly with `assistant.client` if you need it.
575
627
 
576
628
  ### Configuration
@@ -41,20 +41,30 @@ module NitroIntelligence
41
41
  @client ||= Assistants.new(base_url: @base_url, api_key: @api_key, user_id: @user_id)
42
42
  end
43
43
 
44
- # The two calls that identify an assistant take it from here rather than from the caller.
44
+ # The calls that identify an assistant take it from here rather than from the caller.
45
45
  # Everything else is thread-scoped and delegates untouched.
46
+ def run(thread_id:, messages:, **kwargs)
47
+ reject_assistant_id!(kwargs)
48
+ client.run(thread_id:, assistant_id:, messages:, **kwargs)
49
+ end
50
+
46
51
  def await_run(thread_id:, messages:, **kwargs)
47
52
  reject_assistant_id!(kwargs)
48
53
  client.await_run(thread_id:, assistant_id:, messages:, **kwargs)
49
54
  end
50
55
 
56
+ def review(thread_id:, **kwargs)
57
+ reject_assistant_id!(kwargs)
58
+ client.review(thread_id:, assistant_id:, **kwargs)
59
+ end
60
+
51
61
  def review_tool_calls(thread_id:, tool_calls:, **kwargs)
52
62
  reject_assistant_id!(kwargs)
53
63
  client.review_tool_calls(thread_id:, assistant_id:, tool_calls:, **kwargs)
54
64
  end
55
65
 
56
66
  delegate :thread_state, :thread_messages, :tool_calls_pending_review, :tool_calls_under_review,
57
- to: :client
67
+ :under_review, to: :client
58
68
 
59
69
  private
60
70
 
@@ -1,8 +1,9 @@
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
8
9
  class Assistants
@@ -11,10 +12,19 @@ module NitroIntelligence
11
12
  class RunError < StandardError; end
12
13
  class ThreadResumptionError < StandardError; end
13
14
  class ThreadStateError < StandardError; end
15
+ # The review being answered is no longer the one the thread is paused on. A subclass, so that
16
+ # callers rescuing ThreadResumptionError still catch it.
17
+ class ReviewSupersededError < ThreadResumptionError; end
18
+
19
+ # What Assistants answers, with a 400, to a resume of a thread that is no longer paused.
20
+ NOT_INTERRUPTED_REFUSAL = "thread is not in interrupted state".freeze
14
21
 
15
22
  # Assistants answers with a conflict when `ifExists: "raise"` is sent for a thread that already exists.
16
23
  THREAD_CONFLICT_CODE = 409
17
24
 
25
+ # Where a `/runs/wait` response says which run it waited on.
26
+ RUN_LOCATION = %r{/runs/([^/]+)\z}
27
+
18
28
  # Every assistant this gem is built for is served by the same deployment, so a client told
19
29
  # nothing about where to reach one gets that deployment. A host talking to a different one
20
30
  # -- a review environment, a local server -- still says so.
@@ -31,11 +41,13 @@ module NitroIntelligence
31
41
  @base_url = base_url.presence || DEFAULT_BASE_URL
32
42
  @api_key = api_key
33
43
  @user_id = user_id
34
- @tool_call_review_validator = ToolCallReviewValidator.new
35
44
  @graph_ids = {}
36
45
  end
37
46
 
38
- def await_run(thread_id:, assistant_id:, messages:, context: {})
47
+ # Runs the last of `messages` on the thread, seeding a new thread with the ones before it, and
48
+ # waits for the run to end or pause. Returns a RunResult: its `reply` is nil whenever the run
49
+ # paused, and its `review` says what the run is waiting on.
50
+ def run(thread_id:, assistant_id:, messages:, context: {})
39
51
  raise RunError, "messages cannot be empty" if messages.blank?
40
52
 
41
53
  initial_state = messages[0..-2]
@@ -45,6 +57,17 @@ module NitroIntelligence
45
57
  trigger_run(thread_id:, assistant_id:, context:, last_message:)
46
58
  end
47
59
 
60
+ # The run's reply, or nil when it paused for review or ended without one.
61
+ #
62
+ # Deprecated: #run says why there is no reply, and how the platform delivered the one there is.
63
+ def await_run(thread_id:, assistant_id:, messages:, context: {})
64
+ NitroIntelligence.deprecator.warn(
65
+ "#await_run is deprecated and is removed in 5.0; use #run(...).reply, and read #run's " \
66
+ "interrupted? and review for a run paused for review"
67
+ )
68
+ run(thread_id:, assistant_id:, messages:, context:).reply
69
+ end
70
+
48
71
  # The thread's state as Assistants reports it, unformatted. Callers that only want the
49
72
  # conversation should reach for #thread_messages instead.
50
73
  def thread_state(thread_id:)
@@ -57,7 +80,17 @@ module NitroIntelligence
57
80
  messages_in(thread_state(thread_id:))
58
81
  end
59
82
 
83
+ # Every tool call in the thread that has no result yet, whether or not it is held for review,
84
+ # with the id of the message before it.
85
+ #
86
+ # Deprecated: a reviewer decides only the calls the interrupt holds, which #under_review reports.
87
+ # nitro-web's VirtualConfirmationAgent::Client still reads this, since the VCA's interrupt is not
88
+ # in the platform's shape; it goes when the VCA moves onto the platform.
60
89
  def tool_calls_pending_review(thread_id:)
90
+ NitroIntelligence.deprecator.warn(
91
+ "#tool_calls_pending_review is deprecated and is removed in 5.0; use " \
92
+ "#under_review(thread_id:)&.tool_calls for the tool calls awaiting a reviewer's decision"
93
+ )
61
94
  thread_state = get_thread_state(thread_id:)
62
95
  messages = messages_in(thread_state)
63
96
  reviewed_tool_call_ids = tool_messages(messages).map { |message| message["tool_call_id"] }
@@ -83,37 +116,95 @@ module NitroIntelligence
83
116
  # A tool the assistant is not configured to interrupt on runs without review, so an AI message
84
117
  # can mix calls under review with calls that are only waiting to be executed. This reports the
85
118
  # former; #tool_calls_pending_review reports both.
119
+ #
120
+ # Raises ThreadResumptionError when the state cannot be read, as it always has; #under_review is
121
+ # the plain read.
122
+ #
123
+ # Deprecated in favour of #under_review, which reports a pause on the reply as well.
86
124
  def tool_calls_under_review(thread_id:)
87
- ToolCallReviewInterrupt.new(get_thread_state(thread_id:)).tool_calls
125
+ NitroIntelligence.deprecator.warn(
126
+ "#tool_calls_under_review is deprecated and is removed in 5.0; use " \
127
+ "#under_review(thread_id:)&.tool_calls.to_a"
128
+ )
129
+ PendingReview.from_thread_state(get_thread_state(thread_id:))&.tool_calls || []
88
130
  end
89
131
 
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.
132
+ # What the thread is paused on -- its tool calls or its reply -- or nil when it is not waiting
133
+ # on a review.
134
+ def under_review(thread_id:)
135
+ PendingReview.from_thread_state(get_thread_state(thread_id:, error: ThreadStateError))
136
+ end
137
+
138
+ # Resumes a paused thread with a reviewer's decision on whatever it is paused on, and returns the
139
+ # RunResult of the resumed run. Pass exactly one of:
140
+ #
141
+ # - `reply:`, a single review of the held draft: `{"action" => "approve"}`,
142
+ # `{"action" => "edit", "content" => "..."}` or `{"action" => "reject", "message" => "reason"}`;
143
+ # - `tool_calls:`, one review per held tool call, as #review_tool_calls takes them.
95
144
  #
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: {})
145
+ # `reviewer_id` and `reviewed_at` (now, unless given) are sent in the resume run's `config.metadata`,
146
+ # where the platform reads them; it records them and never checks them.
147
+ #
148
+ # Pass the `interrupt_id` the reviewer was shown (`PendingReview#interrupt_id`): a thread no longer
149
+ # paused on it raises ReviewSupersededError instead of applying the decision to whatever the thread
150
+ # waits on now. It is required for a reply, since a new message can replace the draft the
151
+ # reviewer read with another, which an approval without it would decide -- and, with a delivery
152
+ # tool, send unread. For tool calls it is optional. The resume is keyed by the interrupt read
153
+ # here, so it never decides a pause that appeared since; reporting such a resume as
154
+ # ReviewSupersededError is best-effort (see PendingReview#unapplied_resume?), as is reporting one
155
+ # Assistants refuses because another review resumed the thread first.
156
+ def review(thread_id:, assistant_id:, reviewer_id:, reply: nil, tool_calls: nil, interrupt_id: nil, # rubocop:disable Metrics/ParameterLists
157
+ reviewed_at: nil, context: {})
158
+ kind, reviews = review_subject!(reply:, tool_calls:)
159
+ raise ArgumentError, "reviewer_id is required" if reviewer_id.blank?
160
+
161
+ if kind == :reply && interrupt_id.blank?
162
+ raise ArgumentError, "interrupt_id is required to review a reply: pass the interrupt_id of the " \
163
+ "review the reviewer was shown (PendingReview#interrupt_id)"
164
+ end
165
+
166
+ pending_review = pending_review!(thread_id:, kind:, interrupt_id:)
167
+ stamp = reviewer_stamp(reviewer_id, reviewed_at)
168
+
169
+ RunResult.new(resume_review(thread_id:, assistant_id:, pending_review:, reviews:, stamp:, context:))
170
+ rescue ThreadResumptionError => e
171
+ raise unless e.message.include?(NOT_INTERRUPTED_REFUSAL)
172
+
173
+ # Assistants refuses a resume once the thread is no longer paused, which is how the first of
174
+ # two reviews deciding at once wins. The second is superseded.
175
+ superseded!(thread_id, pending_review&.interrupt_id)
176
+ end
177
+
178
+ # Resumes an interrupted thread with one review per tool call the interrupt is holding: a hash
179
+ # keyed by tool call id, or a list of reviews that each carry their tool call's `id`. Each names
180
+ # an `action` -- `approve`, `edit`, `reject` or `respond` -- from the decisions the interrupt
181
+ # allows for that tool. `edit` carries `args`, merged over the arguments the model asked for;
182
+ # `respond` carries the `message` returned to the model as the tool's result; `reject` may carry
183
+ # a `message`, recorded as the reason.
184
+ #
185
+ # A thin wrapper over what #review sends for `tool_calls:`: keyed by the interrupt it read, with
186
+ # `reviewer_id` and `reviewed_at` in `config.metadata` when a reviewer is given, and raising
187
+ # ReviewSupersededError when the resumed run shows the decisions were not applied. Unlike #review,
188
+ # a resume Assistants refuses because the thread is no longer paused is raised as it always was, a
189
+ # ThreadResumptionError carrying the response body, which nitro-web parses. Returns nil.
190
+ #
191
+ # Deprecated in favour of #review, which requires the reviewer and returns the resumed run.
192
+ def review_tool_calls(thread_id:, assistant_id:, tool_calls:, context: {}, reviewer_id: nil, reviewed_at: nil)
193
+ NitroIntelligence.deprecator.warn(
194
+ "#review_tool_calls is deprecated and is removed in 5.0; use " \
195
+ "#review(tool_calls:, reviewer_id:, interrupt_id:), which returns the resumed run's RunResult"
196
+ )
99
197
  thread = get_thread(thread_id:)
100
198
  raise ThreadResumptionError, "Thread #{thread_id} is not in the interrupted state" unless interrupted?(thread)
101
199
 
102
- interrupt = ToolCallReviewInterrupt.new(get_thread_state(thread_id:))
103
- tool_calls_under_review = interrupt.tool_calls
200
+ pending_review = PendingReview.from_thread_state(get_thread_state(thread_id:))
104
201
 
105
- if tool_calls_under_review.empty?
202
+ if pending_review.nil? || pending_review.tool_calls.empty?
106
203
  raise ThreadResumptionError, "Thread #{thread_id} has no tool calls awaiting review"
107
204
  end
108
205
 
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
- )
206
+ stamp = reviewer_id.present? ? reviewer_stamp(reviewer_id, reviewed_at) : nil
207
+ resume_review(thread_id:, assistant_id:, pending_review:, reviews: tool_calls, stamp:, context:)
117
208
 
118
209
  nil
119
210
  end
@@ -132,7 +223,8 @@ module NitroIntelligence
132
223
  thread_response = create_thread(thread_id:, assistant_id:)
133
224
 
134
225
  return if thread_already_exists?(thread_response)
135
- raise ThreadInitializationError, thread_response.body if thread_response.code.to_i != 200
226
+
227
+ check!(thread_response, ThreadInitializationError)
136
228
  return if initial_state.blank?
137
229
 
138
230
  seed_new_thread_state(thread_id:, initial_state:)
@@ -173,11 +265,7 @@ module NitroIntelligence
173
265
  end
174
266
 
175
267
  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"]
268
+ graph_id = parse!(get(path: "/assistants/#{assistant_id}"), ThreadInitializationError)["graph_id"]
181
269
 
182
270
  raise ThreadInitializationError, "Assistant #{assistant_id} has no graph_id" if graph_id.blank?
183
271
 
@@ -185,14 +273,8 @@ module NitroIntelligence
185
273
  end
186
274
 
187
275
  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)
276
+ parse!(post(path: "/threads/#{thread_id}/state", body: { values: { messages: initial_state } }),
277
+ ThreadInitializationError)
196
278
  end
197
279
 
198
280
  def thread_already_exists?(response)
@@ -202,39 +284,28 @@ module NitroIntelligence
202
284
  # The review flows have always raised ThreadResumptionError when a state read fails, and consumers
203
285
  # rescue it as such. A plain read resumes nothing, so #thread_state asks for ThreadStateError.
204
286
  def get_thread_state(thread_id:, error: ThreadResumptionError)
205
- state_response = get(path: "/threads/#{thread_id}/state")
206
-
207
- raise error, state_response.body if state_response.code.to_i != 200
208
-
209
- JSON.parse(state_response.body)
287
+ parse!(get(path: "/threads/#{thread_id}/state"), error)
210
288
  end
211
289
 
212
290
  def get_thread(thread_id:)
213
- thread_response = get(path: "/threads/#{thread_id}")
214
-
215
- raise ThreadResumptionError, thread_response.body if thread_response.code.to_i != 200
216
-
217
- JSON.parse(thread_response.body)
291
+ parse!(get(path: "/threads/#{thread_id}"), ThreadResumptionError)
218
292
  end
219
293
 
220
294
  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
- )
231
-
232
- raise RunError, run_response.body if run_response.code.to_i != 200
295
+ run, = wait_for_run(thread_id:, body: { assistant_id:, context:, input: { messages: [last_message] } },
296
+ error: RunError)
297
+ RunResult.new(run)
298
+ end
233
299
 
234
- run = JSON.parse(run_response.body)
235
- raise_run_error!(run, RunError)
300
+ # Waits on a run of the thread, raising `error` when the request fails or the run reports an
301
+ # `__error__`. Returns the run and the id Assistants gave it, which the wait reports in its
302
+ # `Content-Location` (`/threads/{thread_id}/runs/{run_id}`); nil without one.
303
+ def wait_for_run(thread_id:, body:, error:)
304
+ response = post(path: "/threads/#{thread_id}/runs/wait", body:)
305
+ run = parse!(response, error)
306
+ raise_run_error!(run, error)
236
307
 
237
- Array(run["messages"]).last&.dig("content")
308
+ [run, response["Content-Location"].to_s[RUN_LOCATION, 1]]
238
309
  end
239
310
 
240
311
  def raise_run_error!(run, error)
@@ -246,23 +317,66 @@ module NitroIntelligence
246
317
  end
247
318
 
248
319
  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
- )
320
+ wait_for_run(thread_id:, body: { assistant_id:, command: { resume: }, context: }, error: ThreadResumptionError)
321
+ .first
322
+ end
323
+
324
+ def review_subject!(reply:, tool_calls:)
325
+ subjects = { reply:, tool_calls: }.compact
326
+ return subjects.first if subjects.one?
327
+
328
+ raise ArgumentError, "pass exactly one of reply: or tool_calls:"
329
+ end
330
+
331
+ # The thread has to be paused, on the interrupt the reviewer was shown when they say which, and
332
+ # on the kind of review they are answering. A thread whose run is under way again is not
333
+ # paused, whatever its state still reports.
334
+ def pending_review!(thread_id:, kind:, interrupt_id:)
335
+ pending_review = interrupted?(get_thread(thread_id:)) &&
336
+ PendingReview.from_thread_state(get_thread_state(thread_id:))
337
+
338
+ if !pending_review || (interrupt_id.present? && pending_review.interrupt_id != interrupt_id)
339
+ superseded!(thread_id, interrupt_id)
340
+ end
341
+
342
+ if pending_review.kind != kind
343
+ raise ThreadResumptionError,
344
+ "Thread #{thread_id} is awaiting review of #{PendingReview::KIND_NAMES[pending_review.kind]}, " \
345
+ "not of #{PendingReview::KIND_NAMES[kind]}"
346
+ end
347
+
348
+ pending_review
349
+ end
350
+
351
+ def reviewer_stamp(reviewer_id, reviewed_at)
352
+ reviewed_at = (reviewed_at || Time.now).utc.iso8601 unless reviewed_at.is_a?(String)
353
+
354
+ { "reviewer_id" => reviewer_id.to_s, "reviewed_at" => reviewed_at }
355
+ end
356
+
357
+ # Sends the decisions for `reviews`, validated against the pause, as the resume keyed by the
358
+ # pause's interrupt. A reviewer `stamp` goes in the run's `config.metadata`, where the platform
359
+ # reads it for replies and tool calls alike (LangChain drops extra keys in a tool decision).
360
+ # Raises ReviewSupersededError when the run shows the decisions were not applied.
361
+ def resume_review(thread_id:, assistant_id:, pending_review:, reviews:, stamp:, context:)
362
+ decisions = pending_review.decisions(reviews)
259
363
 
260
- raise ThreadResumptionError, run_response.body if run_response.code.to_i != 200
364
+ body = { assistant_id:, command: { resume: pending_review.resume(decisions) } }
365
+ body[:config] = { metadata: stamp } if stamp
366
+ body[:context] = context
261
367
 
262
- run = JSON.parse(run_response.body)
263
- raise_run_error!(run, ThreadResumptionError)
368
+ run, run_id = wait_for_run(thread_id:, body:, error: ThreadResumptionError)
369
+ return run unless pending_review.unapplied_resume?(run:, run_id:) { get_thread_state(thread_id:) }
264
370
 
265
- run
371
+ superseded!(thread_id, pending_review.interrupt_id, now_paused_on: PendingReview.from_run(run).interrupt_id)
372
+ end
373
+
374
+ def superseded!(thread_id, interrupt_id, now_paused_on: nil)
375
+ message = "Thread #{thread_id} is no longer paused on #{interrupt_id.presence || 'a review'}: " \
376
+ "another review already resumed it, or a new message superseded it"
377
+ message += "; it is now paused on #{now_paused_on}, and the decision was not applied" if now_paused_on
378
+
379
+ raise ReviewSupersededError, message
266
380
  end
267
381
 
268
382
  def interrupted?(thread)
@@ -284,37 +398,39 @@ module NitroIntelligence
284
398
  end
285
399
 
286
400
  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"
401
+ request(Net::HTTP::Get, path:)
402
+ end
290
403
 
291
- request = Net::HTTP::Get.new(uri)
292
- request_headers.each { |k, v| request[k] = v }
404
+ def delete(path:)
405
+ request(Net::HTTP::Delete, path:)
406
+ end
293
407
 
294
- http.request(request)
408
+ def post(path:, body:)
409
+ request(Net::HTTP::Post, path:, body:)
295
410
  end
296
411
 
297
- def delete(path:)
412
+ def request(verb, path:, body: nil)
298
413
  uri = URI("#{base_url}#{path}")
299
414
  http = Net::HTTP.new(uri.host, uri.port)
300
415
  http.use_ssl = uri.scheme == "https"
301
416
 
302
- request = Net::HTTP::Delete.new(uri)
417
+ request = verb.new(uri)
303
418
  request_headers.each { |k, v| request[k] = v }
419
+ request.body = body.to_json unless body.nil?
304
420
 
305
421
  http.request(request)
306
422
  end
307
423
 
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"
424
+ # Anything but a 200 is a failure, raised as `error` with the response body as its message.
425
+ # Thread creation tells a conflict apart before it gets here.
426
+ def check!(response, error)
427
+ raise error, response.body if response.code.to_i != 200
312
428
 
313
- request = Net::HTTP::Post.new(uri)
314
- request_headers.each { |k, v| request[k] = v }
315
- request.body = body.to_json
429
+ response
430
+ end
316
431
 
317
- http.request(request)
432
+ def parse!(response, error)
433
+ JSON.parse(check!(response, error).body)
318
434
  end
319
435
 
320
436
  def request_headers
@@ -2,8 +2,8 @@ require "nitro_intelligence/client/factory"
2
2
 
3
3
  module NitroIntelligence
4
4
  module Client
5
- def self.new(observability_project_slug: nil)
6
- Factory.new(observability_project_slug:).build
5
+ def self.new(observability_project_slug: nil, inference_max_retries: nil, inference_timeout: nil)
6
+ Factory.new(observability_project_slug:, inference_max_retries:, inference_timeout:).build
7
7
  end
8
8
 
9
9
  def self.validate_model(model)