nitro_intelligence 2.3.0 → 2.4.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/docs/README.md +6 -6
- data/lib/nitro_intelligence/agent_server.rb +8 -268
- data/lib/nitro_intelligence/assistants.rb +287 -0
- data/lib/nitro_intelligence/configuration.rb +5 -1
- data/lib/nitro_intelligence/deprecation.rb +11 -0
- data/lib/nitro_intelligence/tool_call_review_validator.rb +1 -1
- data/lib/nitro_intelligence/version.rb +1 -1
- data/lib/nitro_intelligence.rb +24 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b9041d12c07ae072e5a7a5a2c5fccbec275d260dfd2c99cba7a5bc92337eab4d
|
|
4
|
+
data.tar.gz: 496aed9a03587308b2af556fa846686edfee3e3a1aaf4a7a3a686cf4f08b322b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ffa988435a0128d981e79a94f28530d1525b82c231f44e6a3cd87f15d459bcdd27ad2ef19b57499153a0a583256bb7bec2c711f51bd3cf519d9c9827cb080937
|
|
7
|
+
data.tar.gz: 18e594810e1c4815f759375f4692cb726dbc5415c00495aa03b3943d16c77d0079aa115f5aeb9ed368d715429aaafff6967f986c4b1496984160c7cb95c8aeea
|
data/docs/README.md
CHANGED
|
@@ -31,8 +31,8 @@ NitroIntelligence.configure do |config|
|
|
|
31
31
|
},
|
|
32
32
|
]
|
|
33
33
|
|
|
34
|
-
#
|
|
35
|
-
config.
|
|
34
|
+
# Nitro Intelligence Assistants settings (optional)
|
|
35
|
+
config.assistants_config = {} # Hash of Assistants keyword arguments
|
|
36
36
|
|
|
37
37
|
# Model configuration
|
|
38
38
|
config.model_config = {
|
|
@@ -80,7 +80,7 @@ end
|
|
|
80
80
|
| `inference_base_url` | `String` | `""` | Base URL for the LLM inference service |
|
|
81
81
|
| `observability_base_url` | `String` | `""` | Base URL for the Langfuse observability service |
|
|
82
82
|
| `observability_projects` | `Array<Hash>` | `[]` | Langfuse project credentials (slug, id, public_key, secret_key) |
|
|
83
|
-
| `
|
|
83
|
+
| `assistants_config` | `Hash` | `{}` | Credentials for `Assistants.new`. Expected keys: `base_url` (String) — HTTP base URL of Nitro Intelligence Assistants; `api_key` (String) — bearer token; `user_id` (String, default: `"default-user"`) — caller identity |
|
|
84
84
|
| `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) |
|
|
85
85
|
|
|
86
86
|
## Basic Usage
|
|
@@ -489,8 +489,8 @@ client.chat(
|
|
|
489
489
|
)
|
|
490
490
|
```
|
|
491
491
|
|
|
492
|
-
##
|
|
492
|
+
## Nitro Intelligence Assistants
|
|
493
493
|
|
|
494
|
-
|
|
494
|
+
`NitroIntelligence::Assistants` is Nitro Intelligence's lightweight SDK for working with hosted agent threads, runs, and human review flows. It is mainly used to initialize conversation threads, trigger agent runs, inspect agent tool calls pending human approval, and resume interrupted threads after human reviews.
|
|
495
495
|
|
|
496
|
-
For the full
|
|
496
|
+
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).
|
|
@@ -1,272 +1,12 @@
|
|
|
1
|
-
require "
|
|
2
|
-
require "
|
|
3
|
-
require "
|
|
4
|
-
require "nitro_intelligence/tool_call_review_validator"
|
|
1
|
+
require "active_support/deprecation/constant_accessor"
|
|
2
|
+
require "nitro_intelligence/assistants"
|
|
3
|
+
require "nitro_intelligence/deprecation"
|
|
5
4
|
|
|
6
5
|
module NitroIntelligence
|
|
7
|
-
|
|
8
|
-
class ConfigurationError < StandardError; end
|
|
9
|
-
class ThreadInitializationError < StandardError; end
|
|
10
|
-
class RunError < StandardError; end
|
|
11
|
-
class ThreadResumptionError < StandardError; end
|
|
6
|
+
include ActiveSupport::Deprecation::DeprecatedConstantAccessor
|
|
12
7
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def initialize(base_url:, api_key:, user_id: "default-user")
|
|
19
|
-
raise ConfigurationError, "base_url is required" if base_url.blank?
|
|
20
|
-
raise ConfigurationError, "api_key is required" if api_key.blank?
|
|
21
|
-
raise ConfigurationError, "user_id is required" if user_id.blank?
|
|
22
|
-
|
|
23
|
-
@base_url = base_url
|
|
24
|
-
@api_key = api_key
|
|
25
|
-
@user_id = user_id
|
|
26
|
-
@tool_call_review_validator = ToolCallReviewValidator.new
|
|
27
|
-
@graph_ids = {}
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def await_run(thread_id:, assistant_id:, messages:, context: {})
|
|
31
|
-
raise RunError, "messages cannot be empty" if messages.blank?
|
|
32
|
-
|
|
33
|
-
initial_state = messages[0..-2]
|
|
34
|
-
last_message = messages.last
|
|
35
|
-
|
|
36
|
-
initialize_thread_if_needed(thread_id:, assistant_id:, initial_state:)
|
|
37
|
-
trigger_run(thread_id:, assistant_id:, context:, last_message:)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def tool_calls_pending_review(thread_id:)
|
|
41
|
-
thread_state = get_thread_state(thread_id:)
|
|
42
|
-
messages = thread_messages(thread_state)
|
|
43
|
-
reviewed_tool_call_ids = tool_messages(messages).map { |message| message["tool_call_id"] }
|
|
44
|
-
|
|
45
|
-
messages.each_with_index.flat_map do |message, index|
|
|
46
|
-
next [] unless message["type"] == "ai"
|
|
47
|
-
|
|
48
|
-
pending_tool_calls(message, reviewed_tool_call_ids).map do |tool_call|
|
|
49
|
-
{
|
|
50
|
-
"previous_message_id" => index.zero? ? nil : messages[index - 1]&.dig("id"),
|
|
51
|
-
"id" => tool_call["id"],
|
|
52
|
-
"name" => tool_call["name"],
|
|
53
|
-
"args" => tool_call["args"] || {},
|
|
54
|
-
}
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def review_tool_calls(thread_id:, assistant_id:, reviewer_id:, tool_calls:, reviewed_at: DateTime.current.iso8601)
|
|
60
|
-
resume = { reviewer_id:, reviewed_at:, tool_calls: }.with_indifferent_access
|
|
61
|
-
thread = get_thread(thread_id:)
|
|
62
|
-
raise ThreadResumptionError, "Thread #{thread_id} is not in the interrupted state" unless interrupted?(thread)
|
|
63
|
-
|
|
64
|
-
thread_state = get_thread_state(thread_id:)
|
|
65
|
-
|
|
66
|
-
@tool_call_review_validator.validate!(
|
|
67
|
-
thread_state:,
|
|
68
|
-
tool_calls: resume[:tool_calls],
|
|
69
|
-
pending_tool_calls: tool_calls_pending_review(thread_id:)
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
resume_run(
|
|
73
|
-
thread_id:,
|
|
74
|
-
assistant_id:,
|
|
75
|
-
resume:,
|
|
76
|
-
context: interrupt_context(thread_state)
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
nil
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
private
|
|
83
|
-
|
|
84
|
-
# Aegra accepts `initial_state` on thread creation but never applies it, so a brand new thread is
|
|
85
|
-
# seeded through the thread state endpoint instead. A thread that already exists is left untouched:
|
|
86
|
-
# its state was seeded when it was created and has been built up by every run since.
|
|
87
|
-
def initialize_thread_if_needed(thread_id:, assistant_id:, initial_state:)
|
|
88
|
-
thread_response = create_thread(thread_id:, assistant_id:)
|
|
89
|
-
|
|
90
|
-
return if thread_already_exists?(thread_response)
|
|
91
|
-
raise ThreadInitializationError, thread_response.body if thread_response.code.to_i != 200
|
|
92
|
-
return if initial_state.blank?
|
|
93
|
-
|
|
94
|
-
seed_new_thread_state(thread_id:, initial_state:)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Creating the thread and seeding its state are separate requests, so a failure between them leaves
|
|
98
|
-
# an empty thread behind. A retry would find that thread, take it for one already under way, skip
|
|
99
|
-
# seeding and run without the history -- losing the very thing seeding exists for, without an error.
|
|
100
|
-
# Discard the thread instead, so a retry starts over from a clean slate.
|
|
101
|
-
def seed_new_thread_state(thread_id:, initial_state:)
|
|
102
|
-
seed_thread_state(thread_id:, initial_state:)
|
|
103
|
-
rescue
|
|
104
|
-
discard_thread(thread_id:)
|
|
105
|
-
raise
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def discard_thread(thread_id:)
|
|
109
|
-
delete(path: "/threads/#{thread_id}")
|
|
110
|
-
rescue
|
|
111
|
-
# Best effort. The seeding failure is the one worth surfacing, and it is raised either way.
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def create_thread(thread_id:, assistant_id:)
|
|
115
|
-
post(
|
|
116
|
-
path: "/threads",
|
|
117
|
-
body: {
|
|
118
|
-
threadId: thread_id.to_s,
|
|
119
|
-
ifExists: "raise",
|
|
120
|
-
# Without a graph_id, the thread state cannot be updated before the thread's first run.
|
|
121
|
-
metadata: { graph_id: graph_id_for(assistant_id) },
|
|
122
|
-
user_id:,
|
|
123
|
-
}
|
|
124
|
-
)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def graph_id_for(assistant_id)
|
|
128
|
-
@graph_ids[assistant_id] ||= fetch_graph_id(assistant_id)
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def fetch_graph_id(assistant_id)
|
|
132
|
-
assistant_response = get(path: "/assistants/#{assistant_id}")
|
|
133
|
-
|
|
134
|
-
raise ThreadInitializationError, assistant_response.body if assistant_response.code.to_i != 200
|
|
135
|
-
|
|
136
|
-
graph_id = JSON.parse(assistant_response.body)["graph_id"]
|
|
137
|
-
|
|
138
|
-
raise ThreadInitializationError, "Assistant #{assistant_id} has no graph_id" if graph_id.blank?
|
|
139
|
-
|
|
140
|
-
graph_id
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def seed_thread_state(thread_id:, initial_state:)
|
|
144
|
-
state_response = post(
|
|
145
|
-
path: "/threads/#{thread_id}/state",
|
|
146
|
-
body: { values: { messages: initial_state } }
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
raise ThreadInitializationError, state_response.body if state_response.code.to_i != 200
|
|
150
|
-
|
|
151
|
-
JSON.parse(state_response.body)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def thread_already_exists?(response)
|
|
155
|
-
response.code.to_i == THREAD_CONFLICT_CODE
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def get_thread_state(thread_id:)
|
|
159
|
-
state_response = get(path: "/threads/#{thread_id}/state")
|
|
160
|
-
|
|
161
|
-
raise ThreadResumptionError, state_response.body if state_response.code.to_i != 200
|
|
162
|
-
|
|
163
|
-
JSON.parse(state_response.body)
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def get_thread(thread_id:)
|
|
167
|
-
thread_response = get(path: "/threads/#{thread_id}")
|
|
168
|
-
|
|
169
|
-
raise ThreadResumptionError, thread_response.body if thread_response.code.to_i != 200
|
|
170
|
-
|
|
171
|
-
JSON.parse(thread_response.body)
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def trigger_run(thread_id:, assistant_id:, last_message:, context: {})
|
|
175
|
-
run_response = post(
|
|
176
|
-
path: "/threads/#{thread_id}/runs/wait",
|
|
177
|
-
body: {
|
|
178
|
-
assistant_id:,
|
|
179
|
-
context:,
|
|
180
|
-
input: {
|
|
181
|
-
messages: [last_message],
|
|
182
|
-
},
|
|
183
|
-
}
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
raise RunError, run_response.body if run_response.code.to_i != 200
|
|
187
|
-
|
|
188
|
-
run = JSON.parse(run_response.body)
|
|
189
|
-
Array(run["messages"]).last&.dig("content")
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def resume_run(thread_id:, assistant_id:, resume:, context:)
|
|
193
|
-
run_response = post(
|
|
194
|
-
path: "/threads/#{thread_id}/runs/wait",
|
|
195
|
-
body: {
|
|
196
|
-
assistant_id:,
|
|
197
|
-
command: {
|
|
198
|
-
resume:,
|
|
199
|
-
},
|
|
200
|
-
context:,
|
|
201
|
-
}
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
raise ThreadResumptionError, run_response.body if run_response.code.to_i != 200
|
|
205
|
-
|
|
206
|
-
JSON.parse(run_response.body)
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def interrupted?(thread)
|
|
210
|
-
thread["status"] == "interrupted"
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def interrupt_context(thread_state)
|
|
214
|
-
thread_state.dig("interrupts", 0, "value", "context") || {}
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def thread_messages(thread_state)
|
|
218
|
-
Array(thread_state.dig("values", "messages"))
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def tool_messages(messages)
|
|
222
|
-
messages.select { |message| message["type"] == "tool" }
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
def pending_tool_calls(message, reviewed_tool_call_ids)
|
|
226
|
-
Array(message["tool_calls"]).reject do |tool_call|
|
|
227
|
-
reviewed_tool_call_ids.include?(tool_call["id"])
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def get(path:)
|
|
232
|
-
uri = URI("#{base_url}#{path}")
|
|
233
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
234
|
-
http.use_ssl = uri.scheme == "https"
|
|
235
|
-
|
|
236
|
-
request = Net::HTTP::Get.new(uri)
|
|
237
|
-
request_headers.each { |k, v| request[k] = v }
|
|
238
|
-
|
|
239
|
-
http.request(request)
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def delete(path:)
|
|
243
|
-
uri = URI("#{base_url}#{path}")
|
|
244
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
245
|
-
http.use_ssl = uri.scheme == "https"
|
|
246
|
-
|
|
247
|
-
request = Net::HTTP::Delete.new(uri)
|
|
248
|
-
request_headers.each { |k, v| request[k] = v }
|
|
249
|
-
|
|
250
|
-
http.request(request)
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
def post(path:, body:)
|
|
254
|
-
uri = URI("#{base_url}#{path}")
|
|
255
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
256
|
-
http.use_ssl = uri.scheme == "https"
|
|
257
|
-
|
|
258
|
-
request = Net::HTTP::Post.new(uri)
|
|
259
|
-
request_headers.each { |k, v| request[k] = v }
|
|
260
|
-
request.body = body.to_json
|
|
261
|
-
|
|
262
|
-
http.request(request)
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
def request_headers
|
|
266
|
-
{
|
|
267
|
-
"Content-Type" => "application/json",
|
|
268
|
-
"Authorization" => "Bearer #{@api_key}",
|
|
269
|
-
}
|
|
270
|
-
end
|
|
271
|
-
end
|
|
8
|
+
# Deprecated: the agent server is now Nitro Intelligence Assistants. Resolved through
|
|
9
|
+
# `const_missing` so that the old name returns the real class rather than a stand-in for it,
|
|
10
|
+
# keeping `is_a?`, `===`, `rescue` and the nested error constants working on upgrade.
|
|
11
|
+
deprecate_constant :AgentServer, "NitroIntelligence::Assistants", deprecator:
|
|
272
12
|
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "nitro_intelligence/tool_call_review_validator"
|
|
5
|
+
|
|
6
|
+
module NitroIntelligence
|
|
7
|
+
class Assistants
|
|
8
|
+
class ConfigurationError < StandardError; end
|
|
9
|
+
class ThreadInitializationError < StandardError; end
|
|
10
|
+
class RunError < StandardError; end
|
|
11
|
+
class ThreadResumptionError < StandardError; end
|
|
12
|
+
class ThreadStateError < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Assistants answers with a conflict when `ifExists: "raise"` is sent for a thread that already exists.
|
|
15
|
+
THREAD_CONFLICT_CODE = 409
|
|
16
|
+
|
|
17
|
+
attr_reader :base_url, :user_id
|
|
18
|
+
|
|
19
|
+
def initialize(base_url:, api_key:, user_id: "default-user")
|
|
20
|
+
raise ConfigurationError, "base_url is required" if base_url.blank?
|
|
21
|
+
raise ConfigurationError, "api_key is required" if api_key.blank?
|
|
22
|
+
raise ConfigurationError, "user_id is required" if user_id.blank?
|
|
23
|
+
|
|
24
|
+
@base_url = base_url
|
|
25
|
+
@api_key = api_key
|
|
26
|
+
@user_id = user_id
|
|
27
|
+
@tool_call_review_validator = ToolCallReviewValidator.new
|
|
28
|
+
@graph_ids = {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def await_run(thread_id:, assistant_id:, messages:, context: {})
|
|
32
|
+
raise RunError, "messages cannot be empty" if messages.blank?
|
|
33
|
+
|
|
34
|
+
initial_state = messages[0..-2]
|
|
35
|
+
last_message = messages.last
|
|
36
|
+
|
|
37
|
+
initialize_thread_if_needed(thread_id:, assistant_id:, initial_state:)
|
|
38
|
+
trigger_run(thread_id:, assistant_id:, context:, last_message:)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The thread's state as Assistants reports it, unformatted. Callers that only want the
|
|
42
|
+
# conversation should reach for #thread_messages instead.
|
|
43
|
+
def thread_state(thread_id:)
|
|
44
|
+
get_thread_state(thread_id:, error: ThreadStateError)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The thread's messages as Assistants reports them, unformatted, oldest first. Each message
|
|
48
|
+
# carries its own `type` ("human", "ai", "tool", ...), which callers map to their own roles.
|
|
49
|
+
def thread_messages(thread_id:)
|
|
50
|
+
messages_in(thread_state(thread_id:))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def tool_calls_pending_review(thread_id:)
|
|
54
|
+
thread_state = get_thread_state(thread_id:)
|
|
55
|
+
messages = messages_in(thread_state)
|
|
56
|
+
reviewed_tool_call_ids = tool_messages(messages).map { |message| message["tool_call_id"] }
|
|
57
|
+
|
|
58
|
+
messages.each_with_index.flat_map do |message, index|
|
|
59
|
+
next [] unless message["type"] == "ai"
|
|
60
|
+
|
|
61
|
+
pending_tool_calls(message, reviewed_tool_call_ids).map do |tool_call|
|
|
62
|
+
{
|
|
63
|
+
"previous_message_id" => index.zero? ? nil : messages[index - 1]&.dig("id"),
|
|
64
|
+
"id" => tool_call["id"],
|
|
65
|
+
"name" => tool_call["name"],
|
|
66
|
+
"args" => tool_call["args"] || {},
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def review_tool_calls(thread_id:, assistant_id:, reviewer_id:, tool_calls:, reviewed_at: DateTime.current.iso8601)
|
|
73
|
+
resume = { reviewer_id:, reviewed_at:, tool_calls: }.with_indifferent_access
|
|
74
|
+
thread = get_thread(thread_id:)
|
|
75
|
+
raise ThreadResumptionError, "Thread #{thread_id} is not in the interrupted state" unless interrupted?(thread)
|
|
76
|
+
|
|
77
|
+
thread_state = get_thread_state(thread_id:)
|
|
78
|
+
|
|
79
|
+
@tool_call_review_validator.validate!(
|
|
80
|
+
thread_state:,
|
|
81
|
+
tool_calls: resume[:tool_calls],
|
|
82
|
+
pending_tool_calls: tool_calls_pending_review(thread_id:)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
resume_run(
|
|
86
|
+
thread_id:,
|
|
87
|
+
assistant_id:,
|
|
88
|
+
resume:,
|
|
89
|
+
context: interrupt_context(thread_state)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Assistants accepts `initial_state` on thread creation but never applies it, so a brand new thread is
|
|
98
|
+
# seeded through the thread state endpoint instead. A thread that already exists is left untouched:
|
|
99
|
+
# its state was seeded when it was created and has been built up by every run since.
|
|
100
|
+
def initialize_thread_if_needed(thread_id:, assistant_id:, initial_state:)
|
|
101
|
+
thread_response = create_thread(thread_id:, assistant_id:)
|
|
102
|
+
|
|
103
|
+
return if thread_already_exists?(thread_response)
|
|
104
|
+
raise ThreadInitializationError, thread_response.body if thread_response.code.to_i != 200
|
|
105
|
+
return if initial_state.blank?
|
|
106
|
+
|
|
107
|
+
seed_new_thread_state(thread_id:, initial_state:)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Creating the thread and seeding its state are separate requests, so a failure between them leaves
|
|
111
|
+
# an empty thread behind. A retry would find that thread, take it for one already under way, skip
|
|
112
|
+
# seeding and run without the history -- losing the very thing seeding exists for, without an error.
|
|
113
|
+
# Discard the thread instead, so a retry starts over from a clean slate.
|
|
114
|
+
def seed_new_thread_state(thread_id:, initial_state:)
|
|
115
|
+
seed_thread_state(thread_id:, initial_state:)
|
|
116
|
+
rescue
|
|
117
|
+
discard_thread(thread_id:)
|
|
118
|
+
raise
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def discard_thread(thread_id:)
|
|
122
|
+
delete(path: "/threads/#{thread_id}")
|
|
123
|
+
rescue
|
|
124
|
+
# Best effort. The seeding failure is the one worth surfacing, and it is raised either way.
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def create_thread(thread_id:, assistant_id:)
|
|
128
|
+
post(
|
|
129
|
+
path: "/threads",
|
|
130
|
+
body: {
|
|
131
|
+
threadId: thread_id.to_s,
|
|
132
|
+
ifExists: "raise",
|
|
133
|
+
# Without a graph_id, the thread state cannot be updated before the thread's first run.
|
|
134
|
+
metadata: { graph_id: graph_id_for(assistant_id) },
|
|
135
|
+
user_id:,
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def graph_id_for(assistant_id)
|
|
141
|
+
@graph_ids[assistant_id] ||= fetch_graph_id(assistant_id)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def fetch_graph_id(assistant_id)
|
|
145
|
+
assistant_response = get(path: "/assistants/#{assistant_id}")
|
|
146
|
+
|
|
147
|
+
raise ThreadInitializationError, assistant_response.body if assistant_response.code.to_i != 200
|
|
148
|
+
|
|
149
|
+
graph_id = JSON.parse(assistant_response.body)["graph_id"]
|
|
150
|
+
|
|
151
|
+
raise ThreadInitializationError, "Assistant #{assistant_id} has no graph_id" if graph_id.blank?
|
|
152
|
+
|
|
153
|
+
graph_id
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def seed_thread_state(thread_id:, initial_state:)
|
|
157
|
+
state_response = post(
|
|
158
|
+
path: "/threads/#{thread_id}/state",
|
|
159
|
+
body: { values: { messages: initial_state } }
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
raise ThreadInitializationError, state_response.body if state_response.code.to_i != 200
|
|
163
|
+
|
|
164
|
+
JSON.parse(state_response.body)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def thread_already_exists?(response)
|
|
168
|
+
response.code.to_i == THREAD_CONFLICT_CODE
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The review flows have always raised ThreadResumptionError when a state read fails, and consumers
|
|
172
|
+
# rescue it as such. A plain read resumes nothing, so #thread_state asks for ThreadStateError.
|
|
173
|
+
def get_thread_state(thread_id:, error: ThreadResumptionError)
|
|
174
|
+
state_response = get(path: "/threads/#{thread_id}/state")
|
|
175
|
+
|
|
176
|
+
raise error, state_response.body if state_response.code.to_i != 200
|
|
177
|
+
|
|
178
|
+
JSON.parse(state_response.body)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def get_thread(thread_id:)
|
|
182
|
+
thread_response = get(path: "/threads/#{thread_id}")
|
|
183
|
+
|
|
184
|
+
raise ThreadResumptionError, thread_response.body if thread_response.code.to_i != 200
|
|
185
|
+
|
|
186
|
+
JSON.parse(thread_response.body)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def trigger_run(thread_id:, assistant_id:, last_message:, context: {})
|
|
190
|
+
run_response = post(
|
|
191
|
+
path: "/threads/#{thread_id}/runs/wait",
|
|
192
|
+
body: {
|
|
193
|
+
assistant_id:,
|
|
194
|
+
context:,
|
|
195
|
+
input: {
|
|
196
|
+
messages: [last_message],
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
raise RunError, run_response.body if run_response.code.to_i != 200
|
|
202
|
+
|
|
203
|
+
run = JSON.parse(run_response.body)
|
|
204
|
+
Array(run["messages"]).last&.dig("content")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def resume_run(thread_id:, assistant_id:, resume:, context:)
|
|
208
|
+
run_response = post(
|
|
209
|
+
path: "/threads/#{thread_id}/runs/wait",
|
|
210
|
+
body: {
|
|
211
|
+
assistant_id:,
|
|
212
|
+
command: {
|
|
213
|
+
resume:,
|
|
214
|
+
},
|
|
215
|
+
context:,
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
raise ThreadResumptionError, run_response.body if run_response.code.to_i != 200
|
|
220
|
+
|
|
221
|
+
JSON.parse(run_response.body)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def interrupted?(thread)
|
|
225
|
+
thread["status"] == "interrupted"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def interrupt_context(thread_state)
|
|
229
|
+
thread_state.dig("interrupts", 0, "value", "context") || {}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def messages_in(thread_state)
|
|
233
|
+
Array(thread_state.dig("values", "messages"))
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def tool_messages(messages)
|
|
237
|
+
messages.select { |message| message["type"] == "tool" }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def pending_tool_calls(message, reviewed_tool_call_ids)
|
|
241
|
+
Array(message["tool_calls"]).reject do |tool_call|
|
|
242
|
+
reviewed_tool_call_ids.include?(tool_call["id"])
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def get(path:)
|
|
247
|
+
uri = URI("#{base_url}#{path}")
|
|
248
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
249
|
+
http.use_ssl = uri.scheme == "https"
|
|
250
|
+
|
|
251
|
+
request = Net::HTTP::Get.new(uri)
|
|
252
|
+
request_headers.each { |k, v| request[k] = v }
|
|
253
|
+
|
|
254
|
+
http.request(request)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def delete(path:)
|
|
258
|
+
uri = URI("#{base_url}#{path}")
|
|
259
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
260
|
+
http.use_ssl = uri.scheme == "https"
|
|
261
|
+
|
|
262
|
+
request = Net::HTTP::Delete.new(uri)
|
|
263
|
+
request_headers.each { |k, v| request[k] = v }
|
|
264
|
+
|
|
265
|
+
http.request(request)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def post(path:, body:)
|
|
269
|
+
uri = URI("#{base_url}#{path}")
|
|
270
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
271
|
+
http.use_ssl = uri.scheme == "https"
|
|
272
|
+
|
|
273
|
+
request = Net::HTTP::Post.new(uri)
|
|
274
|
+
request_headers.each { |k, v| request[k] = v }
|
|
275
|
+
request.body = body.to_json
|
|
276
|
+
|
|
277
|
+
http.request(request)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def request_headers
|
|
281
|
+
{
|
|
282
|
+
"Content-Type" => "application/json",
|
|
283
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
284
|
+
}
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -10,7 +10,7 @@ module NitroIntelligence
|
|
|
10
10
|
config_accessor :cache_provider, default: NitroIntelligence::NullCache.new
|
|
11
11
|
config_accessor :current_revision, default: ""
|
|
12
12
|
config_accessor :environment, default: "test"
|
|
13
|
-
config_accessor :
|
|
13
|
+
config_accessor :assistants_config, default: {}
|
|
14
14
|
config_accessor :inference_api_key, default: ""
|
|
15
15
|
config_accessor :inference_base_url, default: ""
|
|
16
16
|
config_accessor :model_config, default: {}
|
|
@@ -18,6 +18,10 @@ module NitroIntelligence
|
|
|
18
18
|
config_accessor :observability_projects, default: []
|
|
19
19
|
config_accessor :observability_user_id, default: ""
|
|
20
20
|
|
|
21
|
+
# Deprecated: configure `assistants_config` instead. Keeps its original `{}` default through the
|
|
22
|
+
# deprecation window, so a host building the hash up in place still has one to build on.
|
|
23
|
+
config_accessor :agent_server_config, default: {}
|
|
24
|
+
|
|
21
25
|
class << self
|
|
22
26
|
def configure
|
|
23
27
|
yield config
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
require "active_support/deprecation"
|
|
2
|
+
|
|
3
|
+
module NitroIntelligence
|
|
4
|
+
class << self
|
|
5
|
+
# Deprecations introduced while the agent server was renamed to Nitro Intelligence Assistants.
|
|
6
|
+
# The names they cover are removed in 3.0.
|
|
7
|
+
def deprecator
|
|
8
|
+
@deprecator ||= ActiveSupport::Deprecation.new("3.0", "Nitro Intelligence")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/nitro_intelligence.rb
CHANGED
|
@@ -7,9 +7,11 @@ require "openai"
|
|
|
7
7
|
|
|
8
8
|
require "nitro_intelligence/version"
|
|
9
9
|
require "nitro_intelligence/agent_server"
|
|
10
|
+
require "nitro_intelligence/assistants"
|
|
10
11
|
require "nitro_intelligence/client/base"
|
|
11
12
|
require "nitro_intelligence/client/client"
|
|
12
13
|
require "nitro_intelligence/configuration"
|
|
14
|
+
require "nitro_intelligence/deprecation"
|
|
13
15
|
require "nitro_intelligence/media/image_generation"
|
|
14
16
|
require "nitro_intelligence/models/model_catalog"
|
|
15
17
|
require "nitro_intelligence/observability/project_client_registry"
|
|
@@ -21,8 +23,14 @@ module NitroIntelligence
|
|
|
21
23
|
class << self
|
|
22
24
|
delegate :configure, :config, :logger, :environment, to: :configuration
|
|
23
25
|
|
|
26
|
+
def assistants
|
|
27
|
+
Assistants.new(**assistants_config.symbolize_keys)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Deprecated: use `NitroIntelligence.assistants`.
|
|
24
31
|
def agent_server
|
|
25
|
-
|
|
32
|
+
deprecator.warn("`NitroIntelligence.agent_server` is deprecated. Use `NitroIntelligence.assistants` instead.")
|
|
33
|
+
assistants
|
|
26
34
|
end
|
|
27
35
|
|
|
28
36
|
def cache
|
|
@@ -38,5 +46,20 @@ module NitroIntelligence
|
|
|
38
46
|
base_url: configuration.observability_base_url
|
|
39
47
|
)
|
|
40
48
|
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# A host that has migrated is left alone, so a stale `agent_server_config` cannot override the
|
|
53
|
+
# configuration it was replaced by.
|
|
54
|
+
def assistants_config
|
|
55
|
+
current_config = configuration.assistants_config
|
|
56
|
+
legacy_config = configuration.agent_server_config
|
|
57
|
+
return current_config if current_config.present? || legacy_config.blank?
|
|
58
|
+
|
|
59
|
+
deprecator.warn(
|
|
60
|
+
"`agent_server_config` is deprecated. Configure `assistants_config` instead."
|
|
61
|
+
)
|
|
62
|
+
legacy_config
|
|
63
|
+
end
|
|
41
64
|
end
|
|
42
65
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: nitro_intelligence
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Igor Artemenko
|
|
@@ -76,6 +76,7 @@ files:
|
|
|
76
76
|
- docs/README.md
|
|
77
77
|
- lib/nitro_intelligence.rb
|
|
78
78
|
- lib/nitro_intelligence/agent_server.rb
|
|
79
|
+
- lib/nitro_intelligence/assistants.rb
|
|
79
80
|
- lib/nitro_intelligence/client/base.rb
|
|
80
81
|
- lib/nitro_intelligence/client/client.rb
|
|
81
82
|
- lib/nitro_intelligence/client/factory.rb
|
|
@@ -91,6 +92,7 @@ files:
|
|
|
91
92
|
- lib/nitro_intelligence/client/observed.rb
|
|
92
93
|
- lib/nitro_intelligence/client/observers/langfuse_observer.rb
|
|
93
94
|
- lib/nitro_intelligence/configuration.rb
|
|
95
|
+
- lib/nitro_intelligence/deprecation.rb
|
|
94
96
|
- lib/nitro_intelligence/langfuse_extension.rb
|
|
95
97
|
- lib/nitro_intelligence/langfuse_tracer_provider.rb
|
|
96
98
|
- lib/nitro_intelligence/media/audio.rb
|