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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2151383fb66e0292ac5fbf73f16697e32b9c0f7d64263500babdb505275925cc
4
- data.tar.gz: 91b5f4bbbb3e87f56e883b710f8af8a6c1900e3c1e733d3eba14258bbc8ebf05
3
+ metadata.gz: c43b6d21a0be3ea130217218e82e017b7f01d5be0a8dcafeb55578741f6097fd
4
+ data.tar.gz: c872f0b16af367955b74116a54f62de096688f461e365f04ee2184351c7ca020
5
5
  SHA512:
6
- metadata.gz: d058b2fed129d9c85210558afe41b947a44090a5532aca084a6eff04fa2913f4e0d284f0848818ce98195673833e6d675141ee06464bafcf588cfdf29bfea9f8
7
- data.tar.gz: 10b6c3213894be249388a5bdbc7cc5ef963b904581d00de2d02ac3175aeb9abc9348e959a2f54c8a27b20cb19b1ecdd4c09fdb974325d331367d25df98be73b7
6
+ metadata.gz: 472a5f7d3b53b76f2967cdc38456b0ee5926f29900036bd1ff44ac0d03615e53226d8a47191a75d07727c70f8566a7855b5e3dbf3721dae3554814d8a13d93a4
7
+ data.tar.gz: 4a2c14cc4556cb88a658ae4994af79d2c8a001921accdfb9b731afeb210ea706e4307f9ea45a104abb061254263fa55d23f114007cdef9d35ffb70114c9116c0
data/.yardopts ADDED
@@ -0,0 +1,9 @@
1
+ --protected
2
+ --markup markdown
3
+ --hide-api private
4
+ --readme docs/README.md
5
+ --output-dir doc/api
6
+ lib/**/*.rb
7
+ -
8
+ docs/ASSISTANTS.md
9
+ docs/CHANGELOG.md
data/Rakefile CHANGED
@@ -5,4 +5,24 @@ require "rubocop/rake_task"
5
5
  RSpec::Core::RakeTask.new(:spec)
6
6
  RuboCop::RakeTask.new
7
7
 
8
- task default: %i[spec rubocop]
8
+ # The public API is everything `.yardopts` does not hide as `@api private`, and every part of it
9
+ # carries documentation. Fails on anything undocumented and on any YARD warning, such as a `@param`
10
+ # naming an argument the method does not take.
11
+ desc "Check that the public API is fully documented"
12
+ task :yard do
13
+ require "yard"
14
+ require "nitro_intelligence"
15
+ require_relative "tasks/yard_coverage"
16
+
17
+ stats = YARD::CLI::Stats.new
18
+ stats.run("--list-undoc", "--fail-on-warning")
19
+ undocumented = stats.all_objects.select do |object|
20
+ !YardCoverage.hidden?(object) && YardCoverage.undocumented?(object)
21
+ end
22
+ abort "yard: undocumented public API: #{undocumented.map(&:path).join(', ')}" if undocumented.any?
23
+
24
+ missing = YardCoverage.missing_settings(NitroIntelligence::Configuration.config.keys)
25
+ abort "yard: configuration settings missing from the reference: #{missing.join(', ')}" if missing.any?
26
+ end
27
+
28
+ task default: %i[spec rubocop yard]
@@ -0,0 +1,594 @@
1
+ # Nitro Intelligence Assistants
2
+
3
+ `NitroIntelligence::Assistants` is Nitro Intelligence's Agent management SDK. It wraps the subset of the [Nitro Intelligence Assistants](https://portal.powerapp.cloud/docs/default/system/nip-assistants) API for creating or resuming threads, waiting for agent runs, and supporting human review of tool calls and replies before they take effect.
4
+
5
+ For the service itself — the assistants and graphs it hosts, how threads and their state behave, and the human-in-the-loop model this SDK drives — see the [Nitro Intelligence Assistants documentation](https://portal.powerapp.cloud/docs/default/system/nip-assistants). This page covers only the Ruby client.
6
+
7
+ Most callers should build an instance through Nitro Intelligence configuration:
8
+
9
+ ```ruby
10
+ assistants = NitroIntelligence.assistants
11
+ ```
12
+
13
+ ## Reviews and delivery
14
+
15
+ An assistant can be set up so that a person checks its work before it takes effect, and so that the platform sends its replies itself. All of it is configured on the assistant, not in this client; what changes for the caller is what a run returns and how a review is answered.
16
+
17
+ - **Tool-call review.** The assistant holds the calls to tools listed in its prompt config's `interrupt_on`, and the run pauses until a reviewer approves, edits, rejects or answers each one. Nothing the held calls would do happens before then. See [Make it pause for review](https://github.com/powerhome/nip-assistants/blob/main/docs/tools-and-review.md#make-it-pause-for-review).
18
+ - **Reply review.** With the reserved entry `"__reply__": true` in the same `interrupt_on`, the assistant also holds its final reply, the message a customer would read, as a draft. The run pauses with the draft in [`#run`](#run)'s `review.reply`, and `reply` is `nil` until a reviewer approves, edits or rejects it with [`#review`](#review). Turn it on when a person should read what the assistant says before a customer does. See [Reviewing replies](https://github.com/powerhome/nip-assistants/blob/main/docs/README.md#reviewing-replies).
19
+ - **Delivery tool.** The assistant's `mcp-config.json` can name a tool, on a server the application runs, that sends a reply to the customer -- an SMS, a chat message. The platform calls it with every settled reply: one that was approved, edited, or never under review. A reply approved hours later from a reviewer's screen is then sent by the run that approved it, without the application tracking which drafts are still waiting. The outcome is stamped on the reply and read as [`#run`](#run)'s `delivery`. With a delivery tool the application no longer sends replies: the reply it gets back has already gone out. See [Delivering replies](https://github.com/powerhome/nip-assistants/blob/main/docs/README.md#delivering-replies).
20
+
21
+ ## Review flow walkthrough
22
+
23
+ One conversation through every kind of review, for an assistant that holds calls to `reschedule_appointment`, holds its replies (`__reply__`), and delivers them through a `send_sms` delivery tool.
24
+
25
+ ```ruby
26
+ assistant = NitroIntelligence.assistants["virtual-confirmation"]
27
+
28
+ # 1. Run the customer's message and check whether it paused.
29
+ result = assistant.run(thread_id:, messages: [{ role: "user", content: "Can I move my appointment to Tuesday?" }])
30
+ result.interrupted? # => true
31
+ result.reply # => nil: nothing to send while paused
32
+
33
+ # What it is waiting on: its tool calls, or its reply, never both.
34
+ review = result.review # a NitroIntelligence::PendingReview
35
+ review.kind # => :tool_calls (review.tool_calls? is true, review.reply? false)
36
+ review.tool_calls
37
+ # => [{ "previous_message_id" => "human-1",
38
+ # "id" => "call-1",
39
+ # "name" => "reschedule_appointment",
40
+ # "args" => { "appointment_id" => 42, "day" => "Tuesday", "time" => "10:00" },
41
+ # "allowed_decisions" => ["approve", "edit", "reject", "respond"] }]
42
+ review.interrupt_id # => "interrupt-1": keep it with what the reviewer is shown
43
+ review.reply # => nil on a tool-call pause
44
+
45
+ # 2. Later, away from the run (on the reviewer's screen, say), read the same pause again.
46
+ pending = assistant.under_review(thread_id:)
47
+ pending.tool_calls # => the same calls as review.tool_calls
48
+ pending.interrupt_id # => "interrupt-1"
49
+
50
+ # 3. Approve the tool call. The resumed run pauses again, now on its reply.
51
+ result = assistant.review(thread_id:, reviewer_id: "jane@example.com", interrupt_id: pending.interrupt_id,
52
+ tool_calls: [{ "id" => "call-1", "action" => "approve" }])
53
+ result.review.kind # => :reply
54
+ result.review.tool_calls # => [] on a reply pause
55
+ draft = result.review.reply
56
+ draft.content # => "Done! See you Tuesday at 10."
57
+ draft.description # => "Review this SMS before it is sent"
58
+ draft.allowed_decisions # => ["approve", "edit", "reject"]
59
+
60
+ # 4. Edit the reply. The run ends and the platform delivers it.
61
+ result = assistant.review(thread_id:, reviewer_id: "jane@example.com", interrupt_id: draft.interrupt_id,
62
+ reply: { "action" => "edit", "content" => "Done! You're booked for Tuesday at 10am. Reply STOP to cancel." })
63
+ result.interrupted? # => false
64
+ result.reply # => "Done! You're booked for Tuesday at 10am. Reply STOP to cancel."
65
+ result.delivery.delivered? # => true: already sent by the platform, so show and record it, never send it again
66
+ assistant.under_review(thread_id:) # => nil, nothing left to review
67
+ ```
68
+
69
+ - Step 2 is only needed when the review happens away from the run, on another request or after a page load; otherwise step 1's `result.review` is the same pause.
70
+ - A tool call's `edit` carries `args`, merged over the arguments the model asked for: `{ "id" => "call-1", "action" => "edit", "args" => { "time" => "11:00" } }`. A reply's `edit` carries the whole new `content`. See [`#review`](#review) for every action and its fields.
71
+ - The text the model wrote alongside its tool calls (here "Let me move that for you.") is not part of the review and is never the reply. To show it to the reviewer, read the content of the last AI message with tool calls from `result.messages`, or from [`#thread_messages`](#thread_messages) later.
72
+
73
+ A review the thread has moved past is refused. Had a second reviewer, still looking at the tool call, decided it after step 3:
74
+
75
+ ```ruby
76
+ assistant.review(thread_id:, reviewer_id: "sam@example.com", interrupt_id: "interrupt-1",
77
+ tool_calls: [{ "id" => "call-1", "action" => "reject" }])
78
+ # => raises ReviewSupersededError: Thread thread-1 is no longer paused on interrupt-1: another review
79
+ # already resumed it, or a new message superseded it
80
+ ```
81
+
82
+ Tell that reviewer their decision was not applied, and show them what the thread is waiting on now, if anything. A decision the pause does not allow is refused before anything is sent:
83
+
84
+ ```ruby
85
+ assistant.review(thread_id:, reviewer_id: "jane@example.com", interrupt_id: draft.interrupt_id,
86
+ reply: { "action" => "respond", "message" => "hi" })
87
+ # => raises ThreadResumptionError: Invalid review action `respond` for the reply; allowed: approve, edit, reject
88
+ ```
89
+
90
+ ## Upgrading to 4.0
91
+
92
+ 4.0 replaces the reply-only calls with ones that report what a run is waiting on. The old ones still work, warn through `NitroIntelligence.deprecator` (registered with a Rails application's deprecators, so `config.active_support` deprecation settings apply), and are removed in 5.0:
93
+
94
+ | Deprecated | Use instead |
95
+ | --- | --- |
96
+ | [`#await_run`](#await_run) | [`#run`](#run)`(...).reply`; `#run` also reports a pause and the delivery |
97
+ | [`#tool_calls_under_review`](#tool_calls_under_review) | [`#under_review`](#under_review)`(thread_id:)&.tool_calls.to_a` |
98
+ | [`#tool_calls_pending_review`](#tool_calls_pending_review) | [`#under_review`](#under_review)`(thread_id:)&.tool_calls.to_a`: only the held calls, the ones a reviewer has to decide |
99
+ | [`#review_tool_calls`](#review_tool_calls) | [`#review`](#review)`(tool_calls:, reviewer_id:, interrupt_id:)`, which returns the resumed run's `RunResult` |
100
+
101
+ What to change depends on what the assistant is set up for:
102
+
103
+ - **Every caller.** `#await_run` (and so `run(...).reply`) returns `nil` when the run paused for review or ended without a reply, where it returned the last message's content: a draft awaiting review, a tool-calling turn's working text, or `""`. Handle `nil`, and do not send anything in its place. A reply given as content blocks is now its joined text, not the Array. `ThreadResumptionError` messages raised by [`#review`](#review), and its validation errors, are plain text, not a JSON response body: do not parse them as JSON. Then move to the replacements above.
104
+ - **Before turning reply review on.** Call [`#run`](#run) and check `interrupted?`. Show the reviewer `review.reply.content` and keep `review.interrupt_id` with it, or read the pause later with [`#under_review`](#under_review). Resume with `#review(reply:, reviewer_id:, interrupt_id:)`, which requires the `interrupt_id` the reviewer was shown, and rescue `ReviewSupersededError`: the customer wrote again, or another reviewer decided first, and the draft the reviewer read is gone.
105
+ - **Before turning a delivery tool on.** Stop sending the reply yourself, or the customer gets it twice: the reply `#run` and `#review` return is for display and records. Read `result.delivery&.status` and alert someone when it is `"failed"`, or `"pending"` on a finished run.
106
+ - **Code that builds or inspects resume payloads.** Resumes are keyed by the interrupt they answer, `command.resume = {"<interrupt id>" => {"decisions" => [...]}}`.
107
+ - **Calls that resume a run whose prompt needs variables.** Pass `context:` to [`#review`](#review), as to [`#run`](#run).
108
+
109
+ ## `#initialize`
110
+
111
+ Creates a configured `NitroIntelligence::Assistants` client.
112
+
113
+ In most app code, you should prefer `NitroIntelligence.assistants`, which reads the credentials from `NitroIntelligence.configuration.assistants_config`. Direct initialization is useful in tests or low-level integration code.
114
+
115
+ ### Usage example
116
+
117
+ ```ruby
118
+ assistants = NitroIntelligence::Assistants.new(
119
+ base_url: "https://assistants.example.com",
120
+ api_key: "test-api-key",
121
+ user_id: "default-user"
122
+ )
123
+ ```
124
+
125
+ ## `#run`
126
+
127
+ Initializes a thread if needed, seeds it with the prior messages in the conversation, sends the latest message to the agent, and waits for the run to end or to pause for review. Returns a `NitroIntelligence::RunResult`.
128
+
129
+ This is the main entry point when the host application wants to continue a thread. It replaces [`#await_run`](#await_run), which is `run(...).reply`.
130
+
131
+ | `RunResult` | What it is |
132
+ | --- | --- |
133
+ | `reply` | The text of the run's reply, always a `String`: content given as a list of blocks is reduced to its text blocks, joined. `nil` while the run is paused, and when it ended without one |
134
+ | `interrupted?` | Whether the run paused for review |
135
+ | `review` | What the paused run is waiting on, a `NitroIntelligence::PendingReview`; `nil` when it did not pause |
136
+ | `delivery` | How the platform delivered the reply through the assistant's delivery tool; `nil` when the reply carries no delivery stamp: no reply, or no delivery tool. Read it with `&.` |
137
+ | `messages` | The run's messages as Assistants reports them |
138
+ | `raw` | The `/runs/wait` response, unformatted |
139
+
140
+ A run has at most one reply: its final message, an AI message with text and no tool calls. Text the model writes alongside tool calls is its working text, and is never the reply. So `reply` is `nil`:
141
+
142
+ - while the run is paused: its last AI message is a draft awaiting review, or a turn whose tool calls are held. Do not send it;
143
+ - after a reviewer rejects the reply: the draft is removed, leaving a tool result or the customer's message last, and an earlier turn's text is never returned in its place;
144
+ - when the reply is empty or only whitespace;
145
+ - when the last message is still stamped as awaiting review (`response_metadata.review.type` is `"pending"`), even if the response reports no pause: nobody has reviewed it.
146
+
147
+ A run pauses on its tool calls or on its reply, never both at once, and `review` exposes whichever it is:
148
+
149
+ | `PendingReview` | Present when the run paused on | Fields |
150
+ | --- | --- | --- |
151
+ | `tool_calls` | Tool calls the assistant holds for review | As [`#tool_calls_under_review`](#tool_calls_under_review) reports them; `[]` otherwise |
152
+ | `reply` | The run's reply, when the assistant reviews replies (`__reply__`) | `content` (the draft), `allowed_decisions` (from `approve`, `edit` and `reject`), `description`, `interrupt_id`; `nil` otherwise |
153
+
154
+ `review.kind` is `:tool_calls` or `:reply`, and `review.interrupt_id` identifies the pause; pass it back to [`#review`](#review) so a superseded review is refused.
155
+
156
+ When the assistant has a delivery tool, the platform delivers the reply when the run ends, after any review, and stamps the reply's `response_metadata.delivery`. The platform has then already sent the reply, so `reply` is for display and records: do not send it again. `delivery` reads that stamp:
157
+
158
+ | `delivery` | What it is |
159
+ | --- | --- |
160
+ | `status` | `"delivered"`, `"failed"` or `"pending"`; `delivered?`, `failed?` and `pending?` test for each |
161
+ | `error` | The tool's error when it failed, otherwise `nil` |
162
+ | `attempts` | How many times the platform called the tool |
163
+ | `result` | What the tool returned when it succeeded |
164
+ | `tool` | The delivery tool's name |
165
+ | `delivered_at`, `failed_at` | When the reply was delivered, or when delivery gave up |
166
+
167
+ A failed delivery is not paused or retried by the client: the platform has already retried it. Alert someone, or let the customer's next message bring an answer to both.
168
+
169
+ The platform stamps a settled reply `"pending"` just before it starts delivering it, and replaces the stamp with the outcome. A finished run whose reply is still `"pending"` was cut off before delivery recorded an outcome (a crash, a cancel or a timeout), so the reply may not have been sent: treat it as not sent, like a failed one. The next customer message removes it from the conversation, and the model answers both messages.
170
+
171
+ ### Usage example
172
+
173
+ ```ruby
174
+ assistant = NitroIntelligence.assistants["virtual-confirmation"]
175
+
176
+ # Run a turn.
177
+ result = assistant.run(thread_id: thread_id, messages: messages, context: context)
178
+
179
+ result.interrupted? # => true, the reply is held for review
180
+ result.reply # => nil while held
181
+ result.review.reply.content # => "Sure! You're booked for Tuesday at 10am."
182
+ result.review.reply.allowed_decisions # => ["approve", "edit", "reject"]
183
+ interrupt_id = result.review.interrupt_id # keep it with the card the reviewer sees
184
+ ```
185
+
186
+ ### Run errors
187
+
188
+ Raises `RunError` when Assistants rejects the run request, and when the run itself fails. The message carries the failure Assistants reported:
189
+
190
+ ```
191
+ Error: KeyError: missing variables {'first_name'}
192
+ IncompleteRun: Wait ended before the run completed (status: running)
193
+ ```
194
+
195
+ A run that pauses for review is not a failure: `interrupted?` is true, `reply` is `nil` whether or not it drafted text, and `review` reports what it is waiting on.
196
+
197
+ ### Thread initialization
198
+
199
+ `#run` treats the last entry in `messages` as the message to run, and everything before it as the conversation the agent should already know about. Getting that history in front of the agent takes three requests, because Assistants accepts an `initial_state` on thread creation but never applies it:
200
+
201
+ 1. `GET /assistants/:assistant_id` resolves the assistant's `graph_id`. A `graph_id` is a graph's *name* (for example `confirmation-agent`), which is not the same as the assistant's id — Assistants fails the state update with `Graph not found` when given an id. The lookup is memoized per client, so it costs one request per assistant rather than one per run.
202
+ 2. `POST /threads` creates the thread with `ifExists: "raise"` and tags it with that `graph_id` in its metadata. Without the tag, the thread's state cannot be updated before its first run.
203
+ 3. `POST /threads/:thread_id/state` seeds the thread with the earlier messages.
204
+
205
+ Because `ifExists` is `raise`, Assistants answers with a `409` when the thread is already there. That is not treated as an error — it means the thread is already under way, so the seeding step is skipped and the run proceeds against the state the thread's earlier runs have built up. Seeding only ever happens once, when the thread is first created; a thread whose conversation consists of a single message has nothing to seed and skips step 3 as well.
206
+
207
+ ## `#await_run`
208
+
209
+ > **Deprecated** in 4.0 and removed in 5.0. Use [`#run`](#run)`(...).reply`.
210
+
211
+ [`#run`](#run)'s reply: initializes a thread if needed, seeds it with the prior messages in the conversation, sends the latest message to the agent, and waits for the run to end or pause.
212
+
213
+ Use it when the host application only wants the assistant's reply. Returns `nil` whenever the run paused for review, never the draft awaiting it, and when the run ended without a reply. When the assistant has a delivery tool, the platform has already sent the reply it returns: do not send it again.
214
+
215
+ ### Usage example
216
+
217
+ ```ruby
218
+ assistants = NitroIntelligence.assistants
219
+
220
+ messages = [
221
+ { role: "user", content: "Hello" },
222
+ { role: "assistant", content: "Hi there!" },
223
+ { role: "user", content: "How are you?" }
224
+ ]
225
+
226
+ content = assistants.await_run(
227
+ thread_id: "thread-456",
228
+ assistant_id: "assistant-789",
229
+ messages: messages,
230
+ context: { key: "value" }
231
+ )
232
+ ```
233
+
234
+ ### Response example
235
+
236
+ ```json
237
+ "I'm doing well, thank you!"
238
+ ```
239
+
240
+ ## `#thread_state`
241
+
242
+ Returns a thread's state exactly as Assistants reports it, with no formatting applied.
243
+
244
+ This is the general-purpose read: the response carries the conversation under `values.messages` alongside whatever else Assistants tracks for the thread, such as pending `interrupts`. Callers that only want the conversation should reach for [`#thread_messages`](#thread_messages) instead.
245
+
246
+ Raises `ThreadStateError` when the thread state cannot be fetched, carrying the response body from Assistants as the message. A thread that does not exist is one such case.
247
+
248
+ ### Usage example
249
+
250
+ ```ruby
251
+ assistants = NitroIntelligence.assistants
252
+
253
+ state = assistants.thread_state(thread_id: "thread-456")
254
+ ```
255
+
256
+ ### Response example
257
+
258
+ ```json
259
+ {
260
+ "values": {
261
+ "messages": [
262
+ {
263
+ "type": "human",
264
+ "id": "communication-1",
265
+ "content": "Hello"
266
+ },
267
+ {
268
+ "type": "ai",
269
+ "id": "ai-message-1",
270
+ "content": "Hi there!"
271
+ }
272
+ ]
273
+ },
274
+ "next": []
275
+ }
276
+ ```
277
+
278
+ ## `#thread_messages`
279
+
280
+ Returns a thread's messages exactly as Assistants reports them, oldest first, with no formatting applied.
281
+
282
+ Each message carries its own `type` — `human`, `ai`, `tool` — which callers map onto the roles their own presentation uses. Returns an empty array for a thread whose state holds no messages.
283
+
284
+ Raises `ThreadStateError` under the same conditions as [`#thread_state`](#thread_state).
285
+
286
+ ### Usage example
287
+
288
+ ```ruby
289
+ assistants = NitroIntelligence.assistants
290
+
291
+ messages = assistants.thread_messages(thread_id: "thread-456")
292
+ ```
293
+
294
+ ### Response example
295
+
296
+ ```json
297
+ [
298
+ {
299
+ "type": "human",
300
+ "id": "communication-1",
301
+ "content": "Hello"
302
+ },
303
+ {
304
+ "type": "ai",
305
+ "id": "ai-message-1",
306
+ "content": "Hi there!"
307
+ }
308
+ ]
309
+ ```
310
+
311
+ ## `#tool_calls_pending_review`
312
+
313
+ > **Deprecated** in 4.0 and removed in 5.0. Use [`#under_review`](#under_review)`(thread_id:)&.tool_calls.to_a`, which reports only the calls a reviewer has to decide.
314
+
315
+ Returns every tool call in a thread that has no result yet, across every AI message that made one.
316
+
317
+ That is not the same as the set a reviewer may decide on. A tool the assistant is not configured to interrupt on is auto-approved and runs as soon as the thread resumes, so one AI message can mix calls the interrupt is holding with calls that are only waiting to be executed — and both are reported here. A review interface driven from this list offers decisions on calls the interrupt does not hold, and [`#review_tool_calls`](#review_tool_calls) then refuses the whole submission; [`#tool_calls_under_review`](#tool_calls_under_review) reports exactly what may be reviewed.
318
+
319
+ Each pending tool call has a reference to its `previous_message_id`, which points at the message immediately before the tool-call attempt, which lets clients rebuild the conversation context up until that tool call, allowing reviewers to better judge the sequence of events.
320
+
321
+ ### Usage example
322
+
323
+ ```ruby
324
+ assistants = NitroIntelligence.assistants
325
+
326
+ tool_calls = assistants.tool_calls_pending_review(
327
+ thread_id: "thread-456"
328
+ )
329
+ ```
330
+
331
+ ### Response example
332
+
333
+ ```json
334
+ [
335
+ {
336
+ "previous_message_id": "communication-1",
337
+ "id": "tool_call_id_1",
338
+ "name": "lookup_account",
339
+ "args": {}
340
+ },
341
+ {
342
+ "previous_message_id": "communication-1",
343
+ "id": "tool_call_id_2",
344
+ "name": "lookup_orders",
345
+ "args": {
346
+ "status": "open"
347
+ }
348
+ },
349
+ {
350
+ "previous_message_id": "communication-2",
351
+ "id": "tool_call_id_3",
352
+ "name": "lookup_invoices",
353
+ "args": {
354
+ "limit": 5
355
+ }
356
+ }
357
+ ]
358
+ ```
359
+
360
+ ## `#tool_calls_under_review`
361
+
362
+ > **Deprecated** in 4.0 and removed in 5.0. Use [`#under_review`](#under_review)`(thread_id:)&.tool_calls.to_a`.
363
+
364
+ Returns the tool calls the thread's interrupt is holding, in the order the platform wants decisions for them, each with the `allowed_decisions` a reviewer may take on it. Empty when the thread is not waiting on a review, or is paused on its reply; [`#under_review`](#under_review) reports either. It reads the same as `under_review(...)&.tool_calls || []`, except that it raises `ThreadResumptionError` rather than `ThreadStateError`.
365
+
366
+ A tool the assistant is not configured to interrupt on runs without review, so one AI message can mix calls under review with calls that are only waiting to be executed. This reports the former; [`#tool_calls_pending_review`](#tool_calls_pending_review) reports both. Only the calls reported here may be passed to [`#review_tool_calls`](#review_tool_calls), and every one of them has to be.
367
+
368
+ Raises `ThreadResumptionError` when the thread state cannot be fetched, as [`#tool_calls_pending_review`](#tool_calls_pending_review) does, and when the interrupt holds an action for which the thread carries no matching tool call.
369
+
370
+ ### Usage example
371
+
372
+ ```ruby
373
+ assistants = NitroIntelligence.assistants
374
+
375
+ tool_calls = assistants.tool_calls_under_review(
376
+ thread_id: "thread-456"
377
+ )
378
+ ```
379
+
380
+ ### Response example
381
+
382
+ ```json
383
+ [
384
+ {
385
+ "previous_message_id": "communication-1",
386
+ "id": "tool_call_id_2",
387
+ "name": "send_invoice",
388
+ "args": {
389
+ "amount": 100
390
+ },
391
+ "allowed_decisions": ["approve", "reject"]
392
+ }
393
+ ]
394
+ ```
395
+
396
+ ## `#under_review`
397
+
398
+ Returns what the thread is paused on, as the `review` of a [`#run`](#run) reports it -- its tool calls or its reply -- or `nil` when the thread is not waiting on a review. Use it to show a reviewer the pending review later, away from the run that paused.
399
+
400
+ Raises `ThreadStateError` when the thread state cannot be fetched.
401
+
402
+ ### Usage example
403
+
404
+ ```ruby
405
+ pending = assistant.under_review(thread_id: thread_id)
406
+
407
+ pending.kind # => :tool_calls
408
+ pending.tool_calls # => [{ "id" => "call_1", "name" => "cancel_appointment", "args" => {...}, ... }]
409
+ pending.interrupt_id # => "6f1c..."
410
+ ```
411
+
412
+ ## `#review`
413
+
414
+ Resumes a paused thread with a reviewer's decision on whatever it is paused on, and returns the `RunResult` of the resumed run. Pass exactly one of `reply:` or `tool_calls:`, matching the pause, a `reviewer_id`, and, for a reply, the `interrupt_id` of the review the reviewer was shown.
415
+
416
+ | Pause | Argument | `action` | Fields |
417
+ | --- | --- | --- | --- |
418
+ | Reply | `reply:`, one review hash | `approve` keeps the draft as written | — |
419
+ | | | `edit` replaces it, under the same message id | `content`, required |
420
+ | | | `reject` removes it, and the run ends with no reply | `message`, optional: a reason recorded in Cerebro, never shown to the model |
421
+ | Tool calls | `tool_calls:`, one review per held call | As [`#review_tool_calls`](#review_tool_calls) takes them | |
422
+
423
+ The action must be one of the pause's `allowed_decisions`. A reply allows what the assistant's prompt config lists for `__reply__`, from `approve`, `edit` and `reject`; `respond` is never offered for a reply. A review naming an action that is not allowed, or missing a field it needs, raises `ThreadResumptionError` before anything is sent, as does a reply review sent to a thread paused on tool calls, and the other way round.
424
+
425
+ `reviewer_id` (required) and `reviewed_at` (optional, a `Time` or an ISO 8601 string; now by default) are sent in the resume run's `config.metadata`, where the platform reads them for replies and tool calls alike (LangChain drops extra keys in a tool decision). It records a reply's reviewer on the reply's `response_metadata.review` and in Cerebro, and a tool call's in Cerebro, and never checks either: who may review is the application's decision.
426
+
427
+ The resume is sent as:
428
+
429
+ ```json
430
+ {
431
+ "assistant_id": "assistant-789",
432
+ "command": {
433
+ "resume": {
434
+ "<interrupt id>": {
435
+ "decisions": [
436
+ {
437
+ "type": "approve",
438
+ "reviewer_id": "jane@example.com",
439
+ "reviewed_at": "2026-10-18T14:03:00Z"
440
+ }
441
+ ]
442
+ }
443
+ }
444
+ },
445
+ "config": {
446
+ "metadata": {
447
+ "reviewer_id": "jane@example.com",
448
+ "reviewed_at": "2026-10-18T14:03:00Z"
449
+ }
450
+ },
451
+ "context": {}
452
+ }
453
+ ```
454
+
455
+ Pass the `interrupt_id` of the review the reviewer was shown. It is required for a reply: a new customer message can replace the draft the reviewer read with a newer one, and an approval that did not name the review would decide that one instead -- and, with a delivery tool, send it unread. A reply review without it raises `ArgumentError` before any request. For tool calls it is optional. If the thread has moved on since -- a new customer message superseded the review, or another reviewer decided it first -- it is no longer paused on that interrupt, and `#review` raises `ReviewSupersededError` (a `ThreadResumptionError`) rather than applying the decision to whatever the thread is waiting on now. It raises the same when the thread is not paused at all, or its run is under way again. A tool-call review without `interrupt_id` checks only the kind of pause before resuming.
456
+
457
+ The check and the resume are separate requests, so the thread can move on between them. `#review` keys the resume by the interrupt it read, and Assistants applies a keyed resume to that interrupt only: a stale decision is never applied. Reporting it is best-effort: when the resumed run comes back paused on a different interrupt that sits on a checkpoint another run wrote, `#review` raises `ReviewSupersededError`; when it cannot tell, it returns the result, paused on the newer interrupt with nothing decided. A resume Assistants refuses because another review resumed the thread first is reported as `ReviewSupersededError` too. A tool-call pause reported without an id is resumed unkeyed; a reply pause always has one.
458
+
459
+ The resumed run can pause again: a run whose tool calls were just decided may go on to hold its reply. Read the returned result as you would [`#run`](#run)'s.
460
+
461
+ `context` is sent with the resumed run, as for [`#run`](#run).
462
+
463
+ ### Usage example
464
+
465
+ ```ruby
466
+ # Later, from a reviewer's screen: edit the reply the reviewer was shown.
467
+ result = assistant.review(
468
+ thread_id: thread_id,
469
+ reviewer_id: current_user.email,
470
+ interrupt_id: interrupt_id,
471
+ reply: { "action" => "edit", "content" => "Sure! Tuesday at 10am works. Reply C to confirm." },
472
+ context: context,
473
+ )
474
+
475
+ result.reply # => "Sure! Tuesday at 10am works. Reply C to confirm."
476
+ result.delivery&.status # => "delivered" or "failed"; delivery is nil without a delivery tool
477
+ result.delivery&.error # => nil, or the tool's error when it failed
478
+
479
+ # Or reject it: the run ends with no reply; the message is the reason, for Cerebro only.
480
+ assistant.review(
481
+ thread_id: thread_id,
482
+ reviewer_id: current_user.email,
483
+ interrupt_id: interrupt_id,
484
+ reply: { "action" => "reject", "message" => "Wrong appointment; I'll call the customer." },
485
+ context: context,
486
+ )
487
+
488
+ # Tool calls: one decision per held call, as today.
489
+ pending = assistant.under_review(thread_id: thread_id)
490
+ pending.tool_calls # => [{ "id" => "call_1", "name" => "cancel_appointment", "args" => {...} }]
491
+ assistant.review(
492
+ thread_id: thread_id,
493
+ reviewer_id: current_user.email,
494
+ tool_calls: [{ "id" => "call_1", "action" => "reject", "message" => "Customer called in" }],
495
+ context: context,
496
+ )
497
+
498
+ # Superseded: a reviewer opened a held reply, and before they decided, a new
499
+ # customer message (or another reviewer) moved the thread on.
500
+ held = assistant.under_review(thread_id: thread_id) # held.reply is the draft
501
+ # ...later, when the reviewer clicks Approve:
502
+ begin
503
+ assistant.review(thread_id: thread_id, reviewer_id: current_user.email,
504
+ interrupt_id: held.interrupt_id, reply: { "action" => "approve" },
505
+ context: context)
506
+ rescue NitroIntelligence::Assistants::ReviewSupersededError
507
+ # Reload the thread and show the current pause, if any.
508
+ end
509
+ ```
510
+
511
+ ## `#review_tool_calls`
512
+
513
+ > **Deprecated** in 4.0 and removed in 5.0. Use [`#review`](#review)`(tool_calls:, reviewer_id:, interrupt_id:)`.
514
+
515
+ Resumes an interrupted thread after a human has reviewed the tool calls it is holding.
516
+
517
+ This method fetches the thread to confirm its `status` is still `interrupted`, loads the current thread state, validates the reviews against the interrupt, and resumes the run with one decision per tool call awaiting review.
518
+
519
+ Reviews are keyed by tool call id, as returned by [`#tool_calls_under_review`](#tool_calls_under_review), or given as a list of reviews that each carry their tool call's `id`. Each names an `action`, which must be one the interrupt allows for that tool:
520
+
521
+ | `action` | What it does | Fields |
522
+ | --- | --- | --- |
523
+ | `approve` | Runs the call as the model asked | — |
524
+ | `edit` | Runs the call with the reviewer's arguments | `args`, merged over the arguments the model asked for |
525
+ | `reject` | Skips the call; the model sees a fixed, neutral notice | `message`, optional: a reason recorded in Cerebro, never shown to the model |
526
+ | `respond` | Skips the call and returns the message to the model as the tool's result | `message`, required |
527
+
528
+ An `edit` merges by top-level argument name: each argument the reviewer supplies replaces the model's value for it outright — a nested object is not merged into — and every argument left out keeps the value the model sent.
529
+
530
+ Which actions are allowed for a tool is configured on the assistant's prompt, in Cerebro, and published on the interrupt. A review naming an action the interrupt does not allow raises `Assistants::ThreadResumptionError` before anything is sent, as does a review that omits one of the tool calls under review, edits an argument the call does not have, or leaves out a message `respond` needs.
531
+
532
+ ### Usage example
533
+
534
+ ```ruby
535
+ assistants = NitroIntelligence.assistants
536
+
537
+ assistants.review_tool_calls(
538
+ thread_id: "thread-456",
539
+ assistant_id: "assistant-789",
540
+ tool_calls: {
541
+ "tool_call_id_1" => {
542
+ "action" => "approve"
543
+ },
544
+ "tool_call_id_2" => {
545
+ "action" => "edit",
546
+ "args" => {
547
+ "arg_1" => "new value"
548
+ }
549
+ },
550
+ "tool_call_id_3" => {
551
+ "action" => "reject",
552
+ "message" => "That is the wrong account"
553
+ }
554
+ }
555
+ )
556
+ ```
557
+
558
+ `context` is optional and is sent with the resumed run, exactly as it is for [`#run`](#run). Pass the `prompt_variables` the assistant's prompt needs if the resumed run has to render it again.
559
+
560
+ `reviewer_id` and `reviewed_at` are optional. Given a `reviewer_id`, the resume run's `config.metadata` carries both, as [`#review`](#review) sends them (`reviewed_at` is now unless given), and the platform records the reviewer from it; without one, neither is sent.
561
+
562
+ This method is a thin wrapper over the resume [`#review`](#review) sends for `tool_calls:`. It keys the resume by the interrupt it read, so it never decides a pause that appeared in between, and when the resumed run shows it decided nothing it raises `ReviewSupersededError` (a `ThreadResumptionError`). Unlike `#review`, it does not translate Assistants refusing the resume because the thread is no longer paused: that is raised as before, a `ThreadResumptionError` whose message is the response body. `#review` with `tool_calls:` takes the same reviews, requires a reviewer, also checks an `interrupt_id` before resuming, and returns the resumed run's result.
563
+
564
+ ### Response
565
+
566
+ Returns `nil` when the thread is resumed successfully.
567
+
568
+ Raises `ThreadResumptionError` when the resumed run fails.
569
+
570
+ ## Errors
571
+
572
+ Every error is a `StandardError` raised by the call that hits it.
573
+
574
+ | Error | Raised when |
575
+ | --- | --- |
576
+ | `Assistants::ConfigurationError` | [`#initialize`](#initialize) is given a blank `api_key` or `user_id`. Omitting `api_key` altogether, including from an `assistants_config` with no `definitions`, raises `ArgumentError` instead |
577
+ | `Assistants::ThreadInitializationError` | [`#run`](#run) cannot create or seed a new thread, or the assistant has no graph; the message is the response body |
578
+ | `Assistants::RunError` | [`#run`](#run) is given no messages, Assistants rejects the run, or the run fails (see [Run errors](#run-errors)) |
579
+ | `Assistants::ThreadStateError` | A thread's state cannot be read by [`#thread_state`](#thread_state), [`#thread_messages`](#thread_messages) or [`#under_review`](#under_review); the message is the response body |
580
+ | `Assistants::ThreadResumptionError` | A review cannot be applied (see [`#review`](#review) and [`#review_tool_calls`](#review_tool_calls)) |
581
+ | `Assistants::ReviewSupersededError` | A `ThreadResumptionError`: the thread is no longer paused on the review answered |
582
+ | `AssistantRegistry::UnknownAssistantError` | `NitroIntelligence.assistants[key]` names a key `definitions` does not configure |
583
+ | `Assistant::ConfigurationError` | An assistant's entry lacks its `api_key` or `assistant_id` |
584
+
585
+ ## Subclassing
586
+
587
+ Prefer the public methods above to subclassing. These hooks are public only because nitro-web's VCA client already subclasses `NitroIntelligence::Assistants` to speak its own review protocol, and they are to be retired once it no longer needs to ([#185](https://github.com/powerhome/nitro-intelligence.rb/issues/185)). Until then, a subclass can rely on what follows. `super` into [`#initialize`](#initialize) takes the same arguments, and `Assistants::DEFAULT_USER_ID` is the `user_id` a client given none uses. A subclass may call four private methods, which are part of the public API for subclasses:
588
+
589
+ | Method | What it does |
590
+ | --- | --- |
591
+ | `get_thread(thread_id:)` | Reads the thread (`GET /threads/{id}`), raising `ThreadResumptionError` with the response body when it cannot |
592
+ | `interrupted?(thread)` | Whether a thread `get_thread` read is paused |
593
+ | `get_thread_state(thread_id:, error: ThreadResumptionError)` | Reads the thread's state, raising `error` with the response body when it cannot |
594
+ | `resume_run(thread_id:, assistant_id:, resume:, context:)` | Resumes the thread with `resume` as its `command.resume`, sent as given, and returns the `/runs/wait` response; raises `ThreadResumptionError` with the response body when Assistants refuses it, or the run's failure when it fails |