rails_ai_admin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +458 -0
  3. data/LICENSE +21 -0
  4. data/README.md +292 -0
  5. data/SECURITY.md +14 -0
  6. data/THIRD_PARTY_NOTICES.md +19 -0
  7. data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
  8. data/app/assets/builds/rails_ai_admin.css +345 -0
  9. data/app/assets/builds/rails_ai_admin.js +33 -0
  10. data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
  11. data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
  12. data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
  13. data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
  14. data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
  15. data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
  16. data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
  17. data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
  18. data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
  19. data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
  20. data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
  21. data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
  22. data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
  23. data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
  24. data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
  25. data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
  26. data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
  27. data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
  28. data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
  29. data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
  30. data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
  31. data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
  32. data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
  33. data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
  34. data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
  35. data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
  36. data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
  37. data/app/models/rails_ai_admin/application_record.rb +8 -0
  38. data/app/models/rails_ai_admin/audit_event.rb +12 -0
  39. data/app/models/rails_ai_admin/batch_item.rb +8 -0
  40. data/app/models/rails_ai_admin/operation_run.rb +20 -0
  41. data/app/models/rails_ai_admin/selection.rb +9 -0
  42. data/app/models/rails_ai_admin/selection_item.rb +9 -0
  43. data/app/models/rails_ai_admin/work_item.rb +18 -0
  44. data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
  45. data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
  46. data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
  47. data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
  48. data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
  49. data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
  50. data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
  51. data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
  52. data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
  53. data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
  54. data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
  55. data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
  56. data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
  57. data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
  58. data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
  59. data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
  60. data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
  61. data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
  62. data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
  63. data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
  64. data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
  65. data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
  66. data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
  67. data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
  68. data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
  69. data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
  70. data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
  71. data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
  72. data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
  73. data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
  74. data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
  75. data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
  76. data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
  77. data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
  78. data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
  79. data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
  80. data/app/views/rails_ai_admin/search/index.html.erb +20 -0
  81. data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
  82. data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
  83. data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
  84. data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
  85. data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
  86. data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
  87. data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
  88. data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
  89. data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
  90. data/config/locales/en.yml +549 -0
  91. data/config/routes.rb +64 -0
  92. data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
  93. data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
  94. data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
  95. data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
  96. data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
  97. data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
  98. data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
  99. data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
  100. data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
  101. data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
  102. data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
  103. data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
  104. data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
  105. data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
  106. data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
  107. data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
  108. data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
  109. data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
  110. data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
  111. data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
  112. data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
  113. data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
  114. data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
  115. data/lib/rails_ai_admin/assistant/records.rb +71 -0
  116. data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
  117. data/lib/rails_ai_admin/assistant/tools.rb +477 -0
  118. data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
  119. data/lib/rails_ai_admin/assistant.rb +50 -0
  120. data/lib/rails_ai_admin/authorization/access.rb +296 -0
  121. data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
  122. data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
  123. data/lib/rails_ai_admin/collections/claim.rb +91 -0
  124. data/lib/rails_ai_admin/collections/contract.rb +113 -0
  125. data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
  126. data/lib/rails_ai_admin/collections/queries.rb +79 -0
  127. data/lib/rails_ai_admin/collections/result.rb +15 -0
  128. data/lib/rails_ai_admin/collections/runner.rb +208 -0
  129. data/lib/rails_ai_admin/configuration.rb +178 -0
  130. data/lib/rails_ai_admin/context.rb +29 -0
  131. data/lib/rails_ai_admin/doctor.rb +301 -0
  132. data/lib/rails_ai_admin/engine.rb +24 -0
  133. data/lib/rails_ai_admin/error.rb +16 -0
  134. data/lib/rails_ai_admin/field.rb +625 -0
  135. data/lib/rails_ai_admin/fields/association.rb +188 -0
  136. data/lib/rails_ai_admin/fields/computation.rb +43 -0
  137. data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
  138. data/lib/rails_ai_admin/fields/file_value.rb +23 -0
  139. data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
  140. data/lib/rails_ai_admin/fields/projection.rb +78 -0
  141. data/lib/rails_ai_admin/fields/registry.rb +48 -0
  142. data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
  143. data/lib/rails_ai_admin/fields/type.rb +50 -0
  144. data/lib/rails_ai_admin/forms/plan.rb +90 -0
  145. data/lib/rails_ai_admin/input_schema.rb +76 -0
  146. data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
  147. data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
  148. data/lib/rails_ai_admin/mcp.rb +130 -0
  149. data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
  150. data/lib/rails_ai_admin/navigation/menu.rb +31 -0
  151. data/lib/rails_ai_admin/navigation/settings.rb +28 -0
  152. data/lib/rails_ai_admin/operation.rb +120 -0
  153. data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
  154. data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
  155. data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
  156. data/lib/rails_ai_admin/operations/registry.rb +80 -0
  157. data/lib/rails_ai_admin/operations/result.rb +21 -0
  158. data/lib/rails_ai_admin/operations/runner.rb +633 -0
  159. data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
  160. data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
  161. data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
  162. data/lib/rails_ai_admin/outbox/registry.rb +36 -0
  163. data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
  164. data/lib/rails_ai_admin/outbox/writer.rb +43 -0
  165. data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
  166. data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
  167. data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
  168. data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
  169. data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
  170. data/lib/rails_ai_admin/presentation/history.rb +46 -0
  171. data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
  172. data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
  173. data/lib/rails_ai_admin/presentation/status.rb +40 -0
  174. data/lib/rails_ai_admin/prune.rb +61 -0
  175. data/lib/rails_ai_admin/queries/list.rb +162 -0
  176. data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
  177. data/lib/rails_ai_admin/record_version.rb +40 -0
  178. data/lib/rails_ai_admin/registry.rb +45 -0
  179. data/lib/rails_ai_admin/request_log_filter.rb +24 -0
  180. data/lib/rails_ai_admin/resource.rb +249 -0
  181. data/lib/rails_ai_admin/resource_identity.rb +15 -0
  182. data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
  183. data/lib/rails_ai_admin/selections/builder.rb +162 -0
  184. data/lib/rails_ai_admin/templates/catalog.rb +84 -0
  185. data/lib/rails_ai_admin/templates/inspector.rb +94 -0
  186. data/lib/rails_ai_admin/templates/metadata.rb +48 -0
  187. data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
  188. data/lib/rails_ai_admin/test_kit.rb +103 -0
  189. data/lib/rails_ai_admin/version.rb +5 -0
  190. data/lib/rails_ai_admin/work.rb +48 -0
  191. data/lib/rails_ai_admin.rb +112 -0
  192. data/lib/tasks/rails_ai_admin.rake +58 -0
  193. metadata +328 -0
@@ -0,0 +1,477 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailsAiAdmin
6
+ module Assistant
7
+ # Tool state for one claimed turn: the freshly resolved Context, the chat
8
+ # and a fence that stops a worker whose lease was reassigned.
9
+ # conversation is the server-owned reference of a session without a chat
10
+ # (an MCP client); its proposals become reviews opened by URL.
11
+ Session = ::Data.define(:context, :chat, :fence, :conversation) do
12
+ def initialize(context:, chat:, fence:, conversation: nil) = super
13
+ # Registered resources this actor may currently expose to the model.
14
+ def resources
15
+ RailsAiAdmin.registry.resources.select { |resource| exposed?(resource) }
16
+ end
17
+
18
+ # Unknown, hidden and unexposed resources are indistinguishable.
19
+ def resource(key)
20
+ raise AccessDenied unless key.is_a?(String) && key.match?(/\A[a-z][a-z0-9_]{0,63}\z/)
21
+ resource = RailsAiAdmin.registry.fetch(key)
22
+ raise AccessDenied unless exposed?(resource)
23
+ resource
24
+ rescue ConfigurationError, KeyError
25
+ raise AccessDenied
26
+ end
27
+
28
+ def access(resource) = Authorization::Access.new(context, resource)
29
+
30
+ private
31
+
32
+ def exposed?(resource)
33
+ access(resource).authorize_assistant!
34
+ rescue AccessDenied
35
+ false
36
+ end
37
+ end
38
+
39
+ # Base for admin tools. Each call checks the turn fence and current grants.
40
+ # Results are bounded JSON data for the model; they grant nothing.
41
+ class Tool < ::RubyLLM::Tool
42
+ MAX_RESULT_BYTES = 16_384
43
+ MESSAGES = {
44
+ "NOT_FOUND" => "Not found or not available to this user.",
45
+ "INVALID_QUERY" => "The request was not valid for this resource.",
46
+ "CONFLICT" => "The record changed. Fetch it again before proposing a change.",
47
+ "VALIDATION_FAILED" => "The proposed input is not valid.",
48
+ "UNAVAILABLE" => "This capability is not available."
49
+ }.freeze
50
+
51
+ def initialize(session)
52
+ super()
53
+ @session = session
54
+ end
55
+
56
+ def execute(tool_call: nil, **arguments)
57
+ @session.fence.call
58
+ bounded(perform(tool_call: tool_call, **arguments))
59
+ rescue ArgumentError
60
+ # Missing or unknown model-supplied keywords.
61
+ failure("INVALID_QUERY")
62
+ rescue AccessDenied => error
63
+ failure(error.code == "INVALID_QUERY" ? "INVALID_QUERY" : error.code == "CONFLICT" ? "CONFLICT" : "NOT_FOUND")
64
+ rescue ConfigurationError
65
+ failure("UNAVAILABLE")
66
+ end
67
+
68
+ private
69
+
70
+ def failure(code, **extra) = { "error" => code, "message" => MESSAGES.fetch(code, MESSAGES["UNAVAILABLE"]), **extra }
71
+
72
+ # A chat shows the review in the conversation; a session without one
73
+ # (MCP) records it under its conversation reference for the person to
74
+ # open in the admin.
75
+ def present_review(tool_views, resource, definition, record, proposal, tool_call)
76
+ common = { context: @session.context, resource: resource, definition: definition, record: record,
77
+ intent_ref: proposal.intent.id, input: tool_views.wire_input(proposal.values) }
78
+ if @session.chat
79
+ tool_views.present(**common, chat: @session.chat, call_id: tool_call.id)
80
+ else
81
+ tool_views.present(**common, assistant_call: { conversation: @session.conversation, call_id: tool_call.id })
82
+ end
83
+ end
84
+
85
+ def acknowledgment(surface)
86
+ message = @session.chat ? "A review is shown to the user. Nothing has changed yet; a later message reports the confirmed result." :
87
+ "A review is waiting in the admin. Nothing has changed until the user opens review_url and confirms it."
88
+ { "status" => "awaiting_human_confirmation", "surface" => surface.id, "message" => message }
89
+ end
90
+
91
+ def bounded(result)
92
+ raise ConfigurationError, "Tool result must be a Hash" unless result.is_a?(Hash)
93
+ return result if JSON.generate(result).bytesize <= MAX_RESULT_BYTES
94
+ if result["records"].is_a?(Array)
95
+ records = result["records"].dup
96
+ records.pop while records.any? && JSON.generate(result.merge("records" => records, "truncated" => true)).bytesize > MAX_RESULT_BYTES
97
+ return result.merge("records" => records, "truncated" => true)
98
+ end
99
+ failure("UNAVAILABLE")
100
+ end
101
+
102
+ def time_zone = @session.context.time_zone
103
+
104
+ # Typed JSON for the model (RAA-017): null stays null, booleans and
105
+ # integers keep their JSON types, decimals are exact strings padded to
106
+ # their scale, dates and times are ISO 8601, a select is {value, label}
107
+ # and a reference is {id, label, resource}. A registered field type keeps
108
+ # its formatted text, because its formatter may deliberately redact.
109
+ def typed(field, value, references)
110
+ return nil if value.nil?
111
+ return field.format(value, time_zone: time_zone).truncate(500) if field.plugin?
112
+ case field.scalar_type
113
+ when :boolean, :integer then value
114
+ when :decimal then field.format_decimal(value)
115
+ when :date then value.iso8601
116
+ when :datetime then value.in_time_zone(time_zone).iso8601
117
+ when :time then field.format(value, time_zone: time_zone)
118
+ when :json then JSON.generate(value).truncate(2000)
119
+ when :references
120
+ Array(value).first(50).map do |id|
121
+ reference = references.reference(field, id)
122
+ { "id" => reference.id, "label" => reference.label.to_s.truncate(200), "resource" => reference.visible ? reference.resource_key.to_s : nil }
123
+ end
124
+ when :rich_text then field.format(value, time_zone: time_zone).truncate(2000)
125
+ when :files then Array(value).map { |file| { "filename" => file.filename.truncate(200), "content_type" => file.content_type, "byte_size" => file.byte_size } }
126
+ when :file then { "filename" => value.filename.truncate(200), "content_type" => value.content_type, "byte_size" => value.byte_size }
127
+ when :select then { "value" => value.to_s, "label" => field.format(value, time_zone: time_zone) }
128
+ when :belongs_to
129
+ reference = references.reference(field, value.to_s)
130
+ { "id" => reference.id, "label" => reference.label.to_s.truncate(200), "resource" => reference.visible ? reference.resource_key.to_s : nil }
131
+ else value.to_s.truncate(500)
132
+ end
133
+ end
134
+
135
+ def values(access, record, view, references)
136
+ fields = access.fields(:read, record, view: view).index_by { |field| field.name.to_s }
137
+ access.project(record, view: view).filter_map do |name, value|
138
+ field = fields[name]
139
+ [ name, typed(field, value, references) ] if field
140
+ end.to_h
141
+ end
142
+
143
+ def reference_resolver(access) = Fields::Association.new(access.context, access.resource)
144
+
145
+ def record_entry(access, record, view, references = reference_resolver(access))
146
+ { "id" => record.id.to_s, "label" => access.record_label(record, view: view), "fields" => values(access, record, view, references) }
147
+ end
148
+
149
+ # Records the actor may list but not open are left out, as on the list
150
+ # page (RAA-001), and counted so the model can say so.
151
+ def record_entries(access, records, view)
152
+ resolver = reference_resolver(access)
153
+ access.fields(:read, nil, view: view).select { |field| field.scalar_type == :belongs_to }.each do |field|
154
+ resolver.prime(field, records.filter_map { |record| record.has_attribute?(field.column) ? record.read_attribute(field.column)&.to_s : nil })
155
+ end
156
+ omitted = 0
157
+ entries = records.filter_map do |record|
158
+ record_entry(access, record, view, resolver)
159
+ rescue AccessDenied
160
+ omitted += 1
161
+ nil
162
+ end
163
+ [ entries, omitted ]
164
+ end
165
+
166
+ def wire_filters(filters)
167
+ return nil if filters.nil? || filters == {}
168
+ raise AccessDenied, "INVALID_QUERY" unless filters.is_a?(Hash) && filters.size <= Queries::List::MAX_FILTER_CONDITIONS
169
+ filters.to_h do |name, conditions|
170
+ raise AccessDenied, "INVALID_QUERY" unless conditions.is_a?(Hash)
171
+ [ name.to_s, conditions.to_h { |operator, value| [ operator.to_s, scalar(value) ] } ]
172
+ end
173
+ end
174
+
175
+ def scalar(value)
176
+ case value
177
+ when String then value
178
+ when Integer, Float, true, false then value.to_s
179
+ else raise AccessDenied, "INVALID_QUERY"
180
+ end
181
+ end
182
+
183
+ # The assistant sees the whole policy relation unless it names a granted
184
+ # list tab; a default tab never applies silently (RAA-007).
185
+ def list_scope(scope)
186
+ return Queries::NamedScope::NONE if scope.nil?
187
+ raise AccessDenied, "INVALID_QUERY" unless scope.is_a?(String) && scope.match?(Queries::NamedScope::NAME)
188
+ scope
189
+ end
190
+ end
191
+
192
+ class ListResources < Tool
193
+ description "List the admin resources this user may ask about. Call this first to learn resource keys."
194
+ parameters({ "type" => "object", "properties" => {}, "additionalProperties" => false })
195
+
196
+ def self.tool_name = "list_resources"
197
+
198
+ private
199
+
200
+ def perform(tool_call:)
201
+ { "resources" => @session.resources.map { |resource| { "key" => resource.key.to_s, "label" => resource.label } } }
202
+ end
203
+ end
204
+
205
+ class DescribeResource < Tool
206
+ description "Describe one resource: readable fields, search, filters and the changes you may propose."
207
+ parameters({ "type" => "object", "required" => [ "resource" ], "additionalProperties" => false,
208
+ "properties" => { "resource" => { "type" => "string", "description" => "A key from list_resources" } } })
209
+
210
+ def self.tool_name = "describe_resource"
211
+
212
+ private
213
+
214
+ def perform(tool_call:, resource:)
215
+ resource = @session.resource(resource)
216
+ access = @session.access(resource)
217
+ result = {
218
+ "key" => resource.key.to_s, "label" => resource.label,
219
+ "fields" => access.fields(:read, nil, view: :show).map { |field| field_entry(field) },
220
+ # Match mode per field: "prefix" means "Love" does not find "Ada Lovelace" (RAA-021).
221
+ "search" => access.fields(:search).map { |field| { "field" => field.name.to_s, "match" => (field.options[:searchable] == true ? :prefix : field.options[:searchable]).to_s } },
222
+ "filters" => access.fields(:filter).map { |field| { "field" => field.name.to_s, "operators" => field.filter_operators.map(&:to_s) } },
223
+ "sort" => access.fields(:sort).map { |field| field.name.to_s },
224
+ # Search and count apply no list tab unless one is named (RAA-007).
225
+ "scopes" => access.named_scopes.map { |definition| { "name" => definition.name.to_s, "label" => definition.label, "default_in_ui" => definition.default? } }
226
+ }
227
+ if RailsAiAdmin.configuration.surfaces
228
+ result["operations"] = operations(resource, access)
229
+ result["changes"] = changes(resource, access)
230
+ end
231
+ result
232
+ end
233
+
234
+ # Native changes this resource lets the assistant propose, with the
235
+ # fields the user may set on create or update (never passwords or files).
236
+ def changes(resource, access)
237
+ %w[create update destroy].filter_map do |action|
238
+ next unless resource.assistant_change?(action)
239
+ entry = { "action" => action }
240
+ unless action == "destroy"
241
+ fields = access.fields(:write, action == "create" ? access.relation.new : nil, operation: action.to_sym)
242
+ .reject { |field| ProposeChange::UNSUPPORTED.include?(field.scalar_type) }
243
+ entry["fields"] = fields.map { |field| field_entry(field).merge("required" => action == "create" && field.options[:required] == true) }
244
+ end
245
+ entry
246
+ end
247
+ end
248
+
249
+ def field_entry(field)
250
+ entry = { "name" => field.name.to_s, "label" => field.label, "type" => field.scalar_type.to_s }
251
+ # Operation inputs declare enum values; without them a model can only guess.
252
+ entry["choices"] = field.choice_options.map { |label, value| { "value" => value, "label" => label } } if %i[select enum].include?(field.scalar_type)
253
+ entry
254
+ end
255
+
256
+ # Declared contracts only. Member grants depend on the record, so each
257
+ # proposal is authorized against its record by propose_operation.
258
+ def operations(resource, _access)
259
+ RailsAiAdmin.operation_registry.custom(resource).filter_map do |definition|
260
+ next unless definition.scope == :member && resource.assistant_operation?(definition.key)
261
+ { "key" => definition.key.to_s, "description" => definition.description.to_s.truncate(200),
262
+ "input" => definition.handler_class.input.map { |field| field_entry(field).merge("required" => field.options[:required] == true) } }
263
+ end
264
+ end
265
+ end
266
+
267
+ class SearchRecords < Tool
268
+ PAGE_LIMIT = 20
269
+ MAX_RECORDS = 1_000
270
+ description "Search one resource's records with optional text, typed filters, a list scope and a sort. Returns up to 20 records. " \
271
+ "Every record you may see is searched unless you pass scope. To count records, use count_records instead of paging. " \
272
+ "When has_more is true, pass next_offset as offset to continue, including after truncation."
273
+ parameters({ "type" => "object", "required" => [ "resource" ], "additionalProperties" => false,
274
+ "properties" => {
275
+ "resource" => { "type" => "string" },
276
+ "query" => { "type" => "string", "maxLength" => 200, "description" => "Text matched against searchable fields with each field's match mode" },
277
+ "filters" => { "type" => "object", "description" => "Field name to {\"eq\"|\"gte\"|\"lte\": value}; use select values, not labels",
278
+ "additionalProperties" => { "type" => "object", "additionalProperties" => { "type" => %w[string number boolean] } } },
279
+ "scope" => { "type" => "string", "description" => "Optional scope name from describe_resource" },
280
+ "sort" => { "type" => "string", "description" => "Optional sortable field from describe_resource" },
281
+ "direction" => { "type" => "string", "enum" => %w[asc desc] },
282
+ "page" => { "type" => "integer", "minimum" => 1, "maximum" => 50 },
283
+ "offset" => { "type" => "integer", "minimum" => 0, "maximum" => MAX_RECORDS - 1, "description" => "The next_offset returned by the previous result" }
284
+ } })
285
+
286
+ def self.tool_name = "search_records"
287
+
288
+ private
289
+
290
+ def perform(tool_call:, resource:, query: nil, filters: nil, scope: nil, sort: nil, direction: nil, page: 1, offset: nil)
291
+ resource = @session.resource(resource)
292
+ access = @session.access(resource)
293
+ raise AccessDenied, "INVALID_QUERY" unless page.is_a?(Integer) && page.between?(1, 50)
294
+ raise AccessDenied, "INVALID_QUERY" unless sort.nil? || sort.is_a?(String) && sort.match?(Field::NAME)
295
+ raise AccessDenied, "INVALID_QUERY" unless direction.nil? || %w[asc desc].include?(direction)
296
+ offset = (page - 1) * PAGE_LIMIT if offset.nil?
297
+ raise AccessDenied, "INVALID_QUERY" unless offset.is_a?(Integer) && offset.between?(0, MAX_RECORDS - 1)
298
+ result = Queries::List.new(@session.context, resource).call(search: query.presence, page: page.to_s, filters: wire_filters(filters),
299
+ scope: list_scope(scope), sort: sort, direction: direction || "asc", page_size: [ PAGE_LIMIT, MAX_RECORDS - offset ].min, offset: offset)
300
+ records, omitted = record_entries(access, result.records, :index)
301
+ { "resource" => resource.key.to_s, "applied_scope" => result.scope,
302
+ "applied_sort" => result.sort ? { "field" => result.sort, "direction" => result.direction } : nil,
303
+ "page" => result.number, "offset" => offset, "has_more" => result.has_next,
304
+ "omitted" => omitted, "records" => records }
305
+ end
306
+
307
+ # Leave room for continuation metadata before applying the byte bound.
308
+ # Advance by what the model actually saw, not the number queried.
309
+ def bounded(result)
310
+ return result if result["error"]
311
+ result = super(result.merge("next_offset" => MAX_RECORDS, "limit_reached" => false))
312
+ records = result.fetch("records")
313
+ return failure("UNAVAILABLE") if records.empty? && result["truncated"]
314
+ next_offset = result.fetch("offset") + records.size
315
+ more = result["has_more"] || result["truncated"] == true
316
+ result.merge("next_offset" => more && next_offset < MAX_RECORDS ? next_offset : nil,
317
+ "has_more" => more && next_offset < MAX_RECORDS, "limit_reached" => more && next_offset >= MAX_RECORDS)
318
+ end
319
+ end
320
+
321
+ # One bounded number, so "how many" never means paging (roadmap
322
+ # "aggregate tools"). Same grants, filters and scope rule as search.
323
+ class CountRecords < Tool
324
+ description "Count one resource's records matching optional text, typed filters and a list scope. " \
325
+ "Every record you may see is counted unless you pass scope. Counts above the cap are reported as capped."
326
+ parameters({ "type" => "object", "required" => [ "resource" ], "additionalProperties" => false,
327
+ "properties" => {
328
+ "resource" => { "type" => "string" },
329
+ "query" => { "type" => "string", "maxLength" => 200 },
330
+ "filters" => { "type" => "object", "description" => "Field name to {\"eq\"|\"gte\"|\"lte\": value}",
331
+ "additionalProperties" => { "type" => "object", "additionalProperties" => { "type" => %w[string number boolean] } } },
332
+ "scope" => { "type" => "string", "description" => "Optional scope name from describe_resource" }
333
+ } })
334
+
335
+ def self.tool_name = "count_records"
336
+
337
+ private
338
+
339
+ def perform(tool_call:, resource:, query: nil, filters: nil, scope: nil)
340
+ resource = @session.resource(resource)
341
+ result = Queries::List.new(@session.context, resource).count(search: query.presence, filters: wire_filters(filters), scope: list_scope(scope))
342
+ { "resource" => resource.key.to_s, "applied_scope" => result.scope, "count" => result.count, "capped" => result.capped }
343
+ end
344
+ end
345
+
346
+ class GetRecord < Tool
347
+ description "Read one record's fields by resource key and id."
348
+ parameters({ "type" => "object", "required" => %w[resource id], "additionalProperties" => false,
349
+ "properties" => { "resource" => { "type" => "string" }, "id" => { "type" => "string", "maxLength" => 100 } } })
350
+
351
+ def self.tool_name = "get_record"
352
+
353
+ private
354
+
355
+ def perform(tool_call:, resource:, id:)
356
+ resource = @session.resource(resource)
357
+ access = @session.access(resource)
358
+ raise AccessDenied unless id.is_a?(String) && id.match?(Field::REFERENCE)
359
+ { "resource" => resource.key.to_s, "records" => [ record_entry(access, access.find!(id), :show) ] }
360
+ end
361
+ end
362
+
363
+ # Prepares a native intent and presents its review Surface. The human's own
364
+ # confirmation in that Surface is the only approval; this tool writes no
365
+ # business data and cannot confirm anything.
366
+ class ProposeOperation < Tool
367
+ description "Propose a change for the user to review. It does not change data: the user confirms or ignores the review shown in the conversation."
368
+ parameters({ "type" => "object", "required" => %w[resource id operation input], "additionalProperties" => false,
369
+ "properties" => {
370
+ "resource" => { "type" => "string" }, "id" => { "type" => "string", "maxLength" => 100 },
371
+ "operation" => { "type" => "string", "description" => "An operation key from describe_resource" },
372
+ "input" => { "type" => "object", "description" => "Input values for the operation's declared fields" }
373
+ } })
374
+
375
+ def self.tool_name = "propose_operation"
376
+
377
+ private
378
+
379
+ def perform(tool_call:, resource:, id:, operation:, input:)
380
+ raise ConfigurationError, "Surfaces are disabled" unless RailsAiAdmin.configuration.surfaces
381
+ raise ConfigurationError, "A persisted tool call is required" unless tool_call.respond_to?(:id) && tool_call.id.is_a?(String)
382
+ raise AccessDenied, "INVALID_QUERY" unless input.is_a?(Hash) && operation.is_a?(String) && id.is_a?(String)
383
+ resource = @session.resource(resource)
384
+ definition = RailsAiAdmin.operation_registry.fetch(resource, operation)
385
+ raise AccessDenied unless definition.custom? && definition.scope == :member && resource.assistant_operation?(definition.key)
386
+ tool_views = Assistant.tool_views
387
+ replay = @session.chat && tool_views.assistant_surface(@session.context, @session.chat, tool_call.id)
388
+ return acknowledgment(replay) if replay
389
+ record = @session.access(resource).find!(id, action: definition.key)
390
+ proposal = Operations::Runner.new.preflight(context: @session.context, resource: resource, operation: definition.key,
391
+ target_id: record.id, expected_version: RecordVersion.of(record), input: input, origin: :assistant)
392
+ if proposal.is_a?(Operations::Result)
393
+ return failure(proposal.code == "VALIDATION_FAILED" ? "VALIDATION_FAILED" : proposal.code == "CONFLICT" ? "CONFLICT" : "UNAVAILABLE",
394
+ "fields" => proposal.errors.keys.map(&:to_s))
395
+ end
396
+ acknowledgment(present_review(tool_views, resource, definition, record, proposal, tool_call))
397
+ end
398
+ end
399
+
400
+ # Prepares a native create, update or delete intent and presents its
401
+ # review. Nothing changes until the human confirms that review; the write
402
+ # then runs as the human, with a version check, through the shared Runner.
403
+ class ProposeChange < Tool
404
+ ACTIONS = %w[create update destroy].freeze
405
+ # Values a model cannot sensibly supply: secrets and uploads.
406
+ UNSUPPORTED = %i[password file files].freeze
407
+ description "Propose creating, updating or deleting one record. It does not change data: the user reviews and confirms or " \
408
+ "dismisses it in the conversation. For update, send only the fields to change. describe_resource lists the allowed changes."
409
+ parameters({ "type" => "object", "required" => %w[resource action], "additionalProperties" => false,
410
+ "properties" => {
411
+ "resource" => { "type" => "string" },
412
+ "action" => { "type" => "string", "enum" => ACTIONS },
413
+ "id" => { "type" => "string", "maxLength" => 100, "description" => "The record id; required for update and delete" },
414
+ "values" => { "type" => "object", "description" => "Field names and new values, typed as describe_resource reports" }
415
+ } })
416
+
417
+ def self.tool_name = "propose_change"
418
+
419
+ private
420
+
421
+ def perform(tool_call:, resource:, action:, id: nil, values: {})
422
+ raise ConfigurationError, "Surfaces are disabled" unless RailsAiAdmin.configuration.surfaces
423
+ raise ConfigurationError, "A persisted tool call is required" unless tool_call.respond_to?(:id) && tool_call.id.is_a?(String)
424
+ raise AccessDenied, "INVALID_QUERY" unless ACTIONS.include?(action) && values.is_a?(Hash) && (id.nil? || id.is_a?(String))
425
+ raise AccessDenied, "INVALID_QUERY" if action == "create" ? !id.nil? : id.nil?
426
+ raise AccessDenied, "INVALID_QUERY" if action == "destroy" ? values.any? : (action == "update" && values.empty?)
427
+ resource = @session.resource(resource)
428
+ raise AccessDenied unless resource.assistant_change?(action)
429
+ tool_views = Assistant.tool_views
430
+ replay = @session.chat && tool_views.assistant_surface(@session.context, @session.chat, tool_call.id)
431
+ return acknowledgment(replay) if replay
432
+ access = @session.access(resource)
433
+ record = action == "create" ? access.relation.new : access.find!(id, action: action.to_sym)
434
+ definition = RailsAiAdmin.operation_registry.fetch(resource, action)
435
+ by_name = resource.fields.index_by { |field| field.name.to_s }
436
+ raise AccessDenied, "INVALID_QUERY" if values.keys.any? { |name| UNSUPPORTED.include?(by_name[name.to_s]&.scalar_type) }
437
+ proposal = Operations::Runner.new.preflight(context: @session.context, resource: resource, operation: action,
438
+ target_id: action == "create" ? nil : record.id, expected_version: action == "create" ? nil : RecordVersion.of(record),
439
+ input: values.transform_keys(&:to_s), origin: :assistant)
440
+ if proposal.is_a?(Operations::Result)
441
+ return failure(proposal.code == "VALIDATION_FAILED" ? "VALIDATION_FAILED" : proposal.code == "CONFLICT" ? "CONFLICT" : "UNAVAILABLE",
442
+ "fields" => proposal.errors.to_h.transform_values { |messages| Array(messages).first(3) })
443
+ end
444
+ acknowledgment(present_review(tool_views, resource, definition, record, proposal, tool_call))
445
+ end
446
+ end
447
+
448
+ # A record's audited changes, behind the same view_history? grant as its
449
+ # History page. Field names follow the user's current grants.
450
+ class RecordHistory < Tool
451
+ LIMIT = 20
452
+ description "List the latest audited changes to one record: when, what kind, which fields and by whom. Values are never recorded."
453
+ parameters({ "type" => "object", "required" => %w[resource id], "additionalProperties" => false,
454
+ "properties" => { "resource" => { "type" => "string" }, "id" => { "type" => "string", "maxLength" => 100 } } })
455
+
456
+ def self.tool_name = "record_history"
457
+
458
+ private
459
+
460
+ def perform(tool_call:, resource:, id:)
461
+ resource = @session.resource(resource)
462
+ access = @session.access(resource)
463
+ raise AccessDenied unless id.is_a?(String) && id.match?(Field::REFERENCE)
464
+ record = access.find!(id)
465
+ page = Presentation::History.new(access, record).page
466
+ entries = page.entries.first(LIMIT).map do |entry|
467
+ { "at" => entry.at.in_time_zone(time_zone).iso8601, "change" => entry.operation, "saved" => entry.status == "succeeded",
468
+ "outcome" => entry.code, "fields" => entry.fields, "other_fields" => entry.hidden_fields,
469
+ "by" => entry.actor_ref == @session.context.actor_ref ? "the current user" : entry.actor_ref, "via" => entry.origin }
470
+ end
471
+ { "resource" => resource.key.to_s, "id" => record.id.to_s, "changes" => entries, "more" => page.entries.size > LIMIT || !page.older.nil? }
472
+ end
473
+ end
474
+
475
+ TOOLS = [ ListResources, DescribeResource, SearchRecords, CountRecords, GetRecord, RecordHistory ].freeze
476
+ end
477
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailsAiAdmin
6
+ module Assistant
7
+ # Display entries for one chat, rebuilt per request. Stored tool output is
8
+ # never shown as is: record references are re-read through current grants
9
+ # and Surfaces are restored through the admin scope and policy.
10
+ class Transcript
11
+ MESSAGE_LIMIT = 200
12
+ LINK_LIMIT = 20
13
+ Entry = ::Data.define(:kind, :text, :links, :surface_id, :at)
14
+ Link = ::Data.define(:resource_key, :id, :label)
15
+ ACTIVITY = {
16
+ "list_resources" => "Listed the available resources",
17
+ "describe_resource" => "Read the fields of %<resource>s",
18
+ "search_records" => "Searched %<resource>s",
19
+ "count_records" => "Counted records in %<resource>s",
20
+ "get_record" => "Opened a record in %<resource>s",
21
+ "propose_operation" => "Prepared a review in %<resource>s",
22
+ "propose_change" => "Prepared a review in %<resource>s",
23
+ "record_history" => "Read a record's history in %<resource>s"
24
+ }.freeze
25
+ # The line for a call whose stored result is an error (RAA-018).
26
+ FAILED_ACTIVITY = {
27
+ "list_resources" => "Tried to list the available resources",
28
+ "describe_resource" => "Tried to read the fields of %<resource>s",
29
+ "search_records" => "Tried to search %<resource>s",
30
+ "count_records" => "Tried to count records in %<resource>s",
31
+ "get_record" => "Tried to open a record in %<resource>s",
32
+ "propose_operation" => "Tried to prepare a review in %<resource>s",
33
+ "propose_change" => "Tried to prepare a review in %<resource>s",
34
+ "record_history" => "Tried to read a record's history in %<resource>s"
35
+ }.freeze
36
+ FAILURE_REASONS = {
37
+ "NOT_FOUND" => "not available", "INVALID_QUERY" => "the request was not valid", "CONFLICT" => "the record changed",
38
+ "VALIDATION_FAILED" => "the input was not valid", "UNAVAILABLE" => "not available"
39
+ }.freeze
40
+
41
+ def initialize(context, chat)
42
+ @context, @chat = context, chat
43
+ end
44
+
45
+ def entries
46
+ continuation_ids = @chat.turns.where(kind: "continuation").where.not(user_message_id: nil).pluck(:user_message_id).to_set
47
+ messages = @chat.messages.where.not(role: "system").includes(:ruby_llm_tool_calls, :ruby_llm_parent_tool_call)
48
+ .reorder(id: :desc).limit(MESSAGE_LIMIT).to_a.reverse
49
+ @results = messages.select { |message| message.role == "tool" && message.ruby_llm_parent_tool_call }
50
+ .to_h { |message| [ message.ruby_llm_parent_tool_call.id, message.content ] }
51
+ persisted = messages.flat_map do |message|
52
+ case message.role
53
+ when "user"
54
+ [ entry(continuation_ids.include?(message.id) ? :continuation : :user, message.content, message) ]
55
+ when "assistant"
56
+ activity = message.ruby_llm_tool_calls.map { |call| entry(:activity, activity_text(call), message) }
57
+ message.content.present? ? activity + [ entry(:assistant, message.content, message) ] : activity
58
+ when "tool" then tool_entries(message)
59
+ else []
60
+ end
61
+ end
62
+ persisted + waiting
63
+ end
64
+
65
+ private
66
+
67
+ # Saved turns whose human text the worker has not added to the chat yet,
68
+ # so a sent message shows at once instead of after the provider call.
69
+ def waiting
70
+ @chat.turns.where(status: Turn::ACTIVE, user_message_id: nil).reorder(:sequence).map do |turn|
71
+ Entry.new(turn.kind == "continuation" ? :continuation : :user, turn.content, [], nil, turn.created_at)
72
+ end
73
+ end
74
+
75
+ def entry(kind, text, message, links: [], surface_id: nil)
76
+ Entry.new(kind, text.to_s, links, surface_id, message.created_at)
77
+ end
78
+
79
+ # Derived from the stored result, so a failed call never reads as done.
80
+ def activity_text(call)
81
+ return "Used a tool" unless ACTIVITY.key?(call.name)
82
+ resource = resource_label(call.arguments.is_a?(Hash) ? call.arguments["resource"] : nil)
83
+ code = result_error(call)
84
+ return format(ACTIVITY.fetch(call.name), resource: resource) unless code
85
+ "#{format(FAILED_ACTIVITY.fetch(call.name), resource: resource)} (#{FAILURE_REASONS.fetch(code, 'not available')})"
86
+ end
87
+
88
+ def result_error(call)
89
+ data = JSON.parse(@results.fetch(call.id, nil).to_s)
90
+ data["error"].to_s if data.is_a?(Hash) && data["error"].is_a?(String)
91
+ rescue JSON::ParserError
92
+ nil
93
+ end
94
+
95
+ def resource_label(key)
96
+ resource = exposed(key)
97
+ resource ? resource.label : "a resource"
98
+ end
99
+
100
+ def exposed(key)
101
+ return unless key.is_a?(String)
102
+ resource = RailsAiAdmin.registry.fetch(key)
103
+ Authorization::Access.new(@context, resource).authorize_assistant!
104
+ resource
105
+ rescue AccessDenied, ConfigurationError, KeyError
106
+ nil
107
+ end
108
+
109
+ def tool_entries(message)
110
+ data = JSON.parse(message.content.to_s)
111
+ return [] unless data.is_a?(Hash)
112
+ if data["surface"].is_a?(String) && %w[propose_operation propose_change].include?(message.ruby_llm_parent_tool_call&.name)
113
+ return [ entry(:surface, nil, message, surface_id: data["surface"]) ]
114
+ end
115
+ links = record_links(data["resource"], data["records"])
116
+ links.empty? ? [] : [ entry(:records, nil, message, links: links) ]
117
+ rescue JSON::ParserError
118
+ []
119
+ end
120
+
121
+ # Only records the actor can still see, labelled through current grants.
122
+ def record_links(key, records)
123
+ resource = exposed(key)
124
+ return [] unless resource && records.is_a?(Array)
125
+ ids = records.first(LINK_LIMIT).filter_map { |record| record["id"] if record.is_a?(Hash) && record["id"].is_a?(String) }
126
+ return [] if ids.empty?
127
+ access = Authorization::Access.new(@context, resource)
128
+ found = access.relation.where(resource.model_class.primary_key => ids).index_by { |record| record.id.to_s }
129
+ ids.filter_map do |id|
130
+ record = found[id]
131
+ next unless record
132
+ access.authorize!(:show, record)
133
+ Link.new(resource.key.to_s, id, access.record_label(record))
134
+ rescue AccessDenied
135
+ nil
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Opt-in admin Chat on RubyLLM (ADR 0022). Loaded only when
5
+ # `config.assistant` is enabled, so conventional hosts never load RubyLLM.
6
+ module Assistant
7
+ class << self
8
+ def enabled? = RailsAiAdmin.configuration.assistant == true
9
+
10
+ # True when at least one registered resource is exposed to this actor.
11
+ def available?(context)
12
+ context.is_a?(Context) && RailsAiAdmin.registry.resources.any? do |resource|
13
+ Authorization::Access.new(context, resource).authorize_assistant!
14
+ rescue AccessDenied
15
+ false
16
+ end
17
+ end
18
+
19
+ # The host's per-request provider configuration. nil means the assistant
20
+ # is unavailable for this actor, which is a normal, recoverable state.
21
+ def llm_context(context)
22
+ adapter = RailsAiAdmin.configuration.assistant_adapter.constantize.new
23
+ raise ConfigurationError, "assistant_adapter must define llm_context(context:)" unless adapter.respond_to?(:llm_context)
24
+ value = adapter.llm_context(context: context)
25
+ return nil if value.nil?
26
+ raise ConfigurationError, "llm_context must return a RubyLLM::Context" unless value.is_a?(::RubyLLM::Context)
27
+ value
28
+ end
29
+
30
+ def create_chat!(context)
31
+ Chat.create!(ref: SecureRandom.uuid, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref,
32
+ security_version: context.security_version, model: RailsAiAdmin.configuration.assistant_model,
33
+ provider: RailsAiAdmin.configuration.assistant_provider)
34
+ end
35
+
36
+ def tool_views
37
+ require "rails_ai_admin/integrations/tool_views"
38
+ Integrations::ToolViews
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ require "ruby_llm"
45
+ require_relative "assistant/records"
46
+ require_relative "assistant/tools"
47
+ require_relative "assistant/runtime"
48
+ require_relative "assistant/coordinator"
49
+ require_relative "assistant/continuation"
50
+ require_relative "assistant/transcript"