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 +4 -4
- data/Rakefile +3 -1
- data/docs/README.md +62 -10
- data/lib/nitro_intelligence/assistant.rb +12 -2
- data/lib/nitro_intelligence/assistants.rb +209 -93
- data/lib/nitro_intelligence/client/client.rb +2 -2
- data/lib/nitro_intelligence/client/factory.rb +11 -3
- data/lib/nitro_intelligence/client/handlers/image_handler.rb +3 -1
- data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +24 -5
- data/lib/nitro_intelligence/configuration.rb +2 -0
- data/lib/nitro_intelligence/deprecator.rb +10 -0
- data/lib/nitro_intelligence/langfuse_extension.rb +12 -5
- data/lib/nitro_intelligence/langfuse_tracer_provider.rb +22 -1
- data/lib/nitro_intelligence/pending_review.rb +222 -0
- data/lib/nitro_intelligence/railtie.rb +9 -0
- data/lib/nitro_intelligence/reporter.rb +2 -1
- data/lib/nitro_intelligence/review_decision.rb +34 -0
- data/lib/nitro_intelligence/run_result.rb +129 -0
- data/lib/nitro_intelligence/tool_call_review_interrupt.rb +7 -13
- data/lib/nitro_intelligence/tool_call_review_validator.rb +4 -8
- data/lib/nitro_intelligence/version.rb +1 -1
- data/lib/nitro_intelligence.rb +3 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03f41090e337bd4b4ba8f5caf040355d4b8cfca9d81ffcc9b7a8d91eec2ad64e
|
|
4
|
+
data.tar.gz: 0f83490e7d57c8935cdda63579126c3a4d93b22849c1f0f6015948b94ccbb6b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f908c8f5cfdb7b2b56eb0d4316c1855b6fbdb25ac7355fb0390bbaa42f794e92f44ccc66255c9c8dfcd1b4c552ef8af554f933d3c23f6389a3ec6164b31b6c13
|
|
7
|
+
data.tar.gz: 55b75e6f31b185d3ade2f8939b8ecae69a41a6c54a3df7a1cc8405424b6762e8a788e3e4c4f8680626f4abe638d8027c69fc3e3a4cb2d590b2c196f7faec16f3
|
data/Rakefile
CHANGED
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
|
|
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
|
|
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.
|
|
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
|
-
`
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
|
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/
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
tool_calls_under_review = interrupt.tool_calls
|
|
200
|
+
pending_review = PendingReview.from_thread_state(get_thread_state(thread_id:))
|
|
104
201
|
|
|
105
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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 =
|
|
263
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
http.use_ssl = uri.scheme == "https"
|
|
401
|
+
request(Net::HTTP::Get, path:)
|
|
402
|
+
end
|
|
290
403
|
|
|
291
|
-
|
|
292
|
-
|
|
404
|
+
def delete(path:)
|
|
405
|
+
request(Net::HTTP::Delete, path:)
|
|
406
|
+
end
|
|
293
407
|
|
|
294
|
-
|
|
408
|
+
def post(path:, body:)
|
|
409
|
+
request(Net::HTTP::Post, path:, body:)
|
|
295
410
|
end
|
|
296
411
|
|
|
297
|
-
def
|
|
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 =
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
request.body = body.to_json
|
|
429
|
+
response
|
|
430
|
+
end
|
|
316
431
|
|
|
317
|
-
|
|
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)
|