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.
- checksums.yaml +4 -4
- data/.yardopts +9 -0
- data/Rakefile +21 -1
- data/docs/ASSISTANTS.md +594 -0
- data/docs/CHANGELOG.md +243 -0
- data/docs/README.md +190 -12
- data/lib/nitro_intelligence/assistant.rb +82 -3
- data/lib/nitro_intelligence/assistant_registry.rb +18 -0
- data/lib/nitro_intelligence/assistants.rb +340 -102
- data/lib/nitro_intelligence/client/base.rb +88 -8
- data/lib/nitro_intelligence/client/client.rb +22 -2
- data/lib/nitro_intelligence/client/factory.rb +12 -3
- data/lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb +3 -1
- data/lib/nitro_intelligence/client/handlers/base_handler.rb +17 -2
- data/lib/nitro_intelligence/client/handlers/chat_handler.rb +3 -1
- data/lib/nitro_intelligence/client/handlers/decision_handler.rb +75 -0
- data/lib/nitro_intelligence/client/handlers/image_handler.rb +6 -2
- data/lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb +30 -12
- data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +16 -3
- data/lib/nitro_intelligence/client/handlers/observed/decision_handler.rb +72 -0
- data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +20 -5
- data/lib/nitro_intelligence/client/handlers/observed/text_to_speech_handler.rb +27 -9
- data/lib/nitro_intelligence/client/handlers/text_to_speech_handler.rb +3 -1
- data/lib/nitro_intelligence/client/observed.rb +8 -0
- data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +35 -7
- data/lib/nitro_intelligence/configuration.rb +48 -0
- data/lib/nitro_intelligence/decision.rb +105 -0
- data/lib/nitro_intelligence/deprecator.rb +10 -0
- data/lib/nitro_intelligence/langfuse_extension.rb +1 -0
- data/lib/nitro_intelligence/langfuse_tracer_provider.rb +7 -6
- data/lib/nitro_intelligence/media/audio.rb +6 -0
- data/lib/nitro_intelligence/media/image.rb +14 -1
- data/lib/nitro_intelligence/media/image_generation.rb +14 -2
- data/lib/nitro_intelligence/media/media.rb +4 -0
- data/lib/nitro_intelligence/models/model.rb +7 -0
- data/lib/nitro_intelligence/models/model_catalog.rb +3 -0
- data/lib/nitro_intelligence/models/model_factory.rb +2 -0
- data/lib/nitro_intelligence/null_cache.rb +2 -0
- data/lib/nitro_intelligence/observability/project.rb +41 -2
- data/lib/nitro_intelligence/observability/project_client.rb +10 -1
- data/lib/nitro_intelligence/observability/project_client_registry.rb +18 -0
- data/lib/nitro_intelligence/observability/prompt.rb +4 -3
- data/lib/nitro_intelligence/observability/prompt_resolver.rb +2 -0
- data/lib/nitro_intelligence/observability/prompt_store.rb +9 -0
- data/lib/nitro_intelligence/observability/upload_handler.rb +3 -1
- data/lib/nitro_intelligence/pending_review.rb +257 -0
- data/lib/nitro_intelligence/railtie.rb +10 -0
- data/lib/nitro_intelligence/reporter.rb +29 -0
- data/lib/nitro_intelligence/review_decision.rb +36 -0
- data/lib/nitro_intelligence/run_result.rb +160 -0
- data/lib/nitro_intelligence/tool_call_review_interrupt.rb +9 -13
- data/lib/nitro_intelligence/tool_call_review_validator.rb +6 -8
- data/lib/nitro_intelligence/trace.rb +8 -0
- data/lib/nitro_intelligence/version.rb +2 -1
- data/lib/nitro_intelligence.rb +40 -0
- metadata +12 -1
|
@@ -1,29 +1,64 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require "net/http"
|
|
3
3
|
require "uri"
|
|
4
|
-
require "nitro_intelligence/
|
|
5
|
-
require "nitro_intelligence/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
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
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
tool_calls_under_review = interrupt.tool_calls
|
|
290
|
+
pending_review = PendingReview.from_thread_state(get_thread_state(thread_id:))
|
|
104
291
|
|
|
105
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
+
[run, response["Content-Location"].to_s[RUN_LOCATION, 1]]
|
|
210
440
|
end
|
|
211
441
|
|
|
212
|
-
def
|
|
213
|
-
|
|
442
|
+
def raise_run_error!(run, error)
|
|
443
|
+
failure = run["__error__"]
|
|
444
|
+
return if failure.blank?
|
|
214
445
|
|
|
215
|
-
|
|
446
|
+
detail = failure.is_a?(Hash) ? [failure["error"], failure["message"]].compact.join(": ") : failure.to_s
|
|
447
|
+
raise error, detail
|
|
448
|
+
end
|
|
216
449
|
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
464
|
+
if !pending_review || (interrupt_id.present? && pending_review.interrupt_id != interrupt_id)
|
|
465
|
+
superseded!(thread_id, interrupt_id)
|
|
466
|
+
end
|
|
233
467
|
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
474
|
+
pending_review
|
|
238
475
|
end
|
|
239
476
|
|
|
240
|
-
def
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
raise error, detail
|
|
480
|
+
{ "reviewer_id" => reviewer_id.to_s, "reviewed_at" => reviewed_at }
|
|
246
481
|
end
|
|
247
482
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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 =
|
|
263
|
-
|
|
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
|
|
269
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
http.use_ssl = uri.scheme == "https"
|
|
523
|
+
request(Net::HTTP::Get, path:)
|
|
524
|
+
end
|
|
290
525
|
|
|
291
|
-
|
|
292
|
-
|
|
526
|
+
def delete(path:)
|
|
527
|
+
request(Net::HTTP::Delete, path:)
|
|
528
|
+
end
|
|
293
529
|
|
|
294
|
-
|
|
530
|
+
def post(path:, body:)
|
|
531
|
+
request(Net::HTTP::Post, path:, body:)
|
|
295
532
|
end
|
|
296
533
|
|
|
297
|
-
def
|
|
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 =
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
request.body = body.to_json
|
|
551
|
+
response
|
|
552
|
+
end
|
|
316
553
|
|
|
317
|
-
|
|
554
|
+
def parse!(response, error)
|
|
555
|
+
JSON.parse(check!(response, error).body)
|
|
318
556
|
end
|
|
319
557
|
|
|
320
558
|
def request_headers
|