layered-assistant-rails 0.5.0 → 0.7.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/layered-assistant-rails/SKILL.md +46 -16
  3. data/NOTICE +20 -0
  4. data/README.md +166 -14
  5. data/app/controllers/layered/assistant/application_controller.rb +45 -12
  6. data/app/controllers/layered/assistant/assistants_controller.rb +45 -64
  7. data/app/controllers/layered/assistant/conversations_controller.rb +9 -8
  8. data/app/controllers/layered/assistant/messages_controller.rb +6 -4
  9. data/app/controllers/layered/assistant/panel/conversations_controller.rb +6 -5
  10. data/app/controllers/layered/assistant/panel/messages_controller.rb +5 -3
  11. data/app/controllers/layered/assistant/providers_controller.rb +6 -48
  12. data/app/controllers/layered/assistant/public/assistants_controller.rb +1 -1
  13. data/app/controllers/layered/assistant/public/conversations_controller.rb +3 -3
  14. data/app/controllers/layered/assistant/public/panel/conversations_controller.rb +4 -4
  15. data/app/controllers/layered/assistant/resources_controller.rb +10 -0
  16. data/app/helpers/layered/assistant/messages_helper.rb +27 -87
  17. data/app/javascript/layered_assistant/composer_controller.js +0 -1
  18. data/app/javascript/layered_assistant/index.js +2 -0
  19. data/app/javascript/layered_assistant/markdown_controller.js +19 -0
  20. data/app/javascript/layered_assistant/marked_setup.js +20 -0
  21. data/app/javascript/layered_assistant/message_streaming.js +35 -53
  22. data/app/javascript/layered_assistant/messages_controller.js +1 -1
  23. data/app/javascript/layered_assistant/vendor/dompurify.LICENSE +568 -0
  24. data/app/javascript/layered_assistant/vendor/dompurify.js +1471 -0
  25. data/app/javascript/layered_assistant/vendor/marked.LICENSE +44 -0
  26. data/app/javascript/layered_assistant/vendor/marked.js +8 -0
  27. data/app/jobs/layered/assistant/messages/response_job.rb +11 -2
  28. data/app/layered_resources/layered/assistant/assistant_resource.rb +57 -0
  29. data/app/layered_resources/layered/assistant/model_resource.rb +36 -0
  30. data/app/layered_resources/layered/assistant/persona_resource.rb +35 -0
  31. data/app/layered_resources/layered/assistant/provider_resource.rb +44 -0
  32. data/app/layered_resources/layered/assistant/skill_resource.rb +34 -0
  33. data/app/models/concerns/layered/assistant/ownable.rb +13 -0
  34. data/app/models/layered/assistant/assistant.rb +22 -1
  35. data/app/models/layered/assistant/assistant_tool.rb +18 -0
  36. data/app/models/layered/assistant/conversation.rb +8 -1
  37. data/app/models/layered/assistant/message.rb +10 -23
  38. data/app/models/layered/assistant/persona.rb +2 -1
  39. data/app/models/layered/assistant/provider.rb +9 -2
  40. data/app/models/layered/assistant/skill.rb +2 -1
  41. data/app/services/layered/assistant/chunk_parser.rb +70 -1
  42. data/app/services/layered/assistant/chunk_service.rb +55 -9
  43. data/app/services/layered/assistant/client_service.rb +3 -1
  44. data/app/services/layered/assistant/clients/anthropic.rb +2 -1
  45. data/app/services/layered/assistant/clients/base.rb +1 -1
  46. data/app/services/layered/assistant/clients/openai.rb +10 -9
  47. data/app/services/layered/assistant/messages_service.rb +52 -5
  48. data/app/services/layered/assistant/tool_call_accumulator.rb +37 -0
  49. data/app/services/layered/assistant/tool_definitions_service.rb +36 -0
  50. data/app/services/layered/assistant/tool_registry.rb +33 -0
  51. data/app/services/layered/assistant/tool_runner_service.rb +135 -0
  52. data/app/tools/layered/assistant/tool.rb +174 -0
  53. data/app/views/layered/assistant/conversations/_form.html.erb +3 -3
  54. data/app/views/layered/assistant/conversations/index.html.erb +11 -9
  55. data/app/views/layered/assistant/conversations/show.html.erb +2 -2
  56. data/app/views/layered/assistant/messages/_composer_fields.html.erb +3 -3
  57. data/app/views/layered/assistant/messages/_message.html.erb +28 -25
  58. data/app/views/layered/assistant/messages/_tool_message.html.erb +19 -0
  59. data/app/views/layered/assistant/messages/create.turbo_stream.erb +1 -1
  60. data/app/views/layered/assistant/messages/index.html.erb +6 -6
  61. data/app/views/layered/assistant/panel/conversations/_header.html.erb +3 -3
  62. data/app/views/layered/assistant/panel/conversations/index.html.erb +6 -6
  63. data/app/views/layered/assistant/panel/conversations/new.html.erb +4 -4
  64. data/app/views/layered/assistant/panel/messages/create.turbo_stream.erb +1 -1
  65. data/app/views/layered/assistant/providers/new.html.erb +63 -5
  66. data/app/views/layered/assistant/public/assistants/index.html.erb +3 -3
  67. data/app/views/layered/assistant/public/conversations/show.html.erb +3 -3
  68. data/app/views/layered/assistant/public/messages/create.turbo_stream.erb +1 -1
  69. data/app/views/layered/assistant/public/panel/conversations/_header.html.erb +3 -3
  70. data/app/views/layered/assistant/public/panel/conversations/index.html.erb +5 -5
  71. data/app/views/layered/assistant/public/panel/conversations/new.html.erb +2 -2
  72. data/app/views/layered/assistant/public/panel/messages/create.turbo_stream.erb +1 -1
  73. data/app/views/layered/assistant/setup/_setup.html.erb +93 -74
  74. data/config/importmap.rb +4 -0
  75. data/config/routes.rb +17 -15
  76. data/data/models.json +37 -24
  77. data/db/migrate/20260804000000_add_resolved_model_to_layered_assistant_messages.rb +5 -0
  78. data/db/migrate/20260831000000_add_tool_calling_to_layered_assistant_messages.rb +11 -0
  79. data/db/migrate/20260901000000_create_layered_assistant_assistant_tools.rb +13 -0
  80. data/db/migrate/20260901000001_add_user_to_layered_assistant_conversations.rb +5 -0
  81. data/lib/generators/layered/assistant/templates/initializer.rb +56 -18
  82. data/lib/layered/assistant/version.rb +1 -1
  83. data/lib/layered/assistant.rb +14 -5
  84. metadata +45 -43
  85. data/app/controllers/layered/assistant/models_controller.rb +0 -61
  86. data/app/controllers/layered/assistant/personas_controller.rb +0 -60
  87. data/app/controllers/layered/assistant/skills_controller.rb +0 -60
  88. data/app/views/layered/assistant/assistants/_form.html.erb +0 -61
  89. data/app/views/layered/assistant/assistants/edit.html.erb +0 -6
  90. data/app/views/layered/assistant/assistants/index.html.erb +0 -52
  91. data/app/views/layered/assistant/assistants/new.html.erb +0 -6
  92. data/app/views/layered/assistant/models/_form.html.erb +0 -30
  93. data/app/views/layered/assistant/models/edit.html.erb +0 -9
  94. data/app/views/layered/assistant/models/index.html.erb +0 -53
  95. data/app/views/layered/assistant/models/new.html.erb +0 -9
  96. data/app/views/layered/assistant/personas/_form.html.erb +0 -31
  97. data/app/views/layered/assistant/personas/edit.html.erb +0 -6
  98. data/app/views/layered/assistant/personas/index.html.erb +0 -40
  99. data/app/views/layered/assistant/personas/new.html.erb +0 -6
  100. data/app/views/layered/assistant/providers/_form.html.erb +0 -81
  101. data/app/views/layered/assistant/providers/edit.html.erb +0 -6
  102. data/app/views/layered/assistant/providers/index.html.erb +0 -46
  103. data/app/views/layered/assistant/skills/_form.html.erb +0 -30
  104. data/app/views/layered/assistant/skills/edit.html.erb +0 -6
  105. data/app/views/layered/assistant/skills/index.html.erb +0 -40
  106. data/app/views/layered/assistant/skills/new.html.erb +0 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9419d936b5247ec519e481b02b7ead93a0e85bd6720a7b4298d145810c1405be
4
- data.tar.gz: 5f9058cdae99fa50b3bf704853b74205f9a544e8c8430611ceb6ba1a509356bf
3
+ metadata.gz: '0849058555cc1dd6e7a2dffb73b3919086413374156a1c3300999eaa543c8a4b'
4
+ data.tar.gz: fa46c8ac4e95587216f467be30eb25d8c01c427d642b589b63a0ab849984e4fb
5
5
  SHA512:
6
- metadata.gz: d5111d8c91517082ac5ab5eea935df4fbf73f0952c7455fcf6ac98f1fadab19f842aa0bf2483c60cdf1d8aaf2cfdb2cb812f488e7352195586038337f3e2314a
7
- data.tar.gz: 3776256743b2734f9ae369508770e8cdc327e17299de34d01af376ba594485b4e55d2b160ce4b4820968c657fedcca2ede227067810a7dcb6f134e29dc5dd7ca
6
+ metadata.gz: 6b4ae1f8d54d5a753b9560fd532cb04e80cbe1959a728deaa9dfaaa82f83d5a7a65edef6a341a61879f73d1c22d76785692ef430242c7e8f995abc19754bac83
7
+ data.tar.gz: d43a15e7ed0b30c8a85dc578a4887b46b537d37e2d881f83fc2135539e60a8b4751dc3bc06fba37ed75e3ee0281b2fa52e089594abf91d31017a94156b065c0a
@@ -59,26 +59,18 @@ Until configured, every request returns 403. Public routes under `/layered/assis
59
59
 
60
60
  ## Scoping (multi-tenant ownership)
61
61
 
62
- Engine models with a polymorphic `owner` association (assistants, personas, providers, models, skills, conversations) can be scoped per request. The block receives the model class and returns an `ActiveRecord::Relation`:
62
+ Assistants, personas, providers, skills and conversations include `Ownable`, giving them a polymorphic `owner` and an `owned_by` scope. Controllers stamp the owner on create and filter by it on read, defaulting to the signed-in user - no configuration required.
63
63
 
64
- ```ruby
65
- Layered::Assistant.scope do |model_class|
66
- model_class.where(owner: current_user)
67
- end
68
- ```
69
-
70
- Scope only conversations, leave the rest unscoped:
64
+ To move the boundary elsewhere, e.g. to an organisation:
71
65
 
72
66
  ```ruby
73
- Layered::Assistant.scope do |model_class|
74
- if model_class == Layered::Assistant::Conversation
75
- model_class.where(owner: current_user)
76
- else
77
- model_class.all
78
- end
67
+ Layered::Assistant.owner do
68
+ current_user.organisation
79
69
  end
80
70
  ```
81
71
 
72
+ A nil owner means reads return no records and creates raise `Layered::Assistant::MissingOwnerError`, rather than persisting a record that every scoped read would hide.
73
+
82
74
  Ownership is enforced **at the controller layer via `scoped()`**, not via model validations. Out-of-scope IDs return 404.
83
75
 
84
76
  ## Optional settings
@@ -87,6 +79,7 @@ Ownership is enforced **at the controller layer via `scoped()`**, not via model
87
79
  Layered::Assistant.log_errors = true # log API errors to stdout
88
80
  Layered::Assistant.api_request_timeout = 210 # total streaming API timeout (seconds)
89
81
  Layered::Assistant.skip_db_encryption = true # dev/test only - skip encryption on Provider#secret
82
+ Layered::Assistant.max_tool_cycles = 10 # rounds of tool calls one prompt may trigger
90
83
  ```
91
84
 
92
85
  `Provider#secret` is encrypted with Rails encrypted attributes, so the host app must have `bin/rails db:encryption:init` keys configured (or set `skip_db_encryption = true` for dev/test).
@@ -152,10 +145,47 @@ All under `Layered::Assistant::*`, tables prefixed `layered_assistant_`. Inherit
152
145
  | `Assistant` | A configured assistant: model + persona + skills |
153
146
  | `AssistantSkill` | Join between assistant and skill |
154
147
  | `Conversation` | A chat session with an assistant, owned polymorphically |
155
- | `Message` | A single message in a conversation; supports streaming |
148
+ | `Message` | A single message in a conversation; supports streaming. A `tool` role message records what a tool returned |
156
149
 
157
150
  Enums are stored as **strings**, not integers.
158
151
 
152
+ ## Tools
153
+
154
+ Tools let an assistant call into the host app before it answers. Define them in `app/tools` as subclasses of `Layered::Assistant::Tool` and list the classes in the initialiser:
155
+
156
+ ```ruby
157
+ # app/tools/weather_tool.rb
158
+ class WeatherTool < Layered::Assistant::Tool
159
+ description "Get the current weather for a city."
160
+
161
+ argument :city, :string, required: true, description: "The city to look up."
162
+ argument :units, :string, description: "celsius or fahrenheit", enum: %w[celsius fahrenheit]
163
+
164
+ def call(city:, units: "celsius")
165
+ { city: city, temperature: Weather.for(city).temperature(units) }
166
+ end
167
+ end
168
+
169
+ # config/initializers/layered_assistant.rb
170
+ Layered::Assistant.tools do
171
+ [ WeatherTool ]
172
+ end
173
+ ```
174
+
175
+ The block is called per request, not read at boot, so tool classes reload in development.
176
+
177
+ Class-level DSL: `description`, `argument(name, type, required:, description:, enum:, items:)`, `tool_name` (defaults to the class name minus its `Tool` suffix, namespaces hyphenated), `self.public =`. Argument types: `:string`, `:integer`, `:number`, `:boolean`, `:array` (with `items:`), `:object`. Subclassing a tool inherits `description`, the `public` flag and the parent's arguments (a child may redeclare one by name); `tool_name` is derived per class rather than inherited.
178
+
179
+ Inside `#call`: `message`, `conversation`, `owner`. Return a string or anything responding to `#to_json`. Raising is safe - the error is handed back to the model as the tool's result rather than failing the response.
180
+
181
+ **Public assistants:** a conversation with a public assistant has no owner. Tools are private by default and are withheld from those conversations; declare `self.public = true` to opt a safe tool in. Otherwise scope a tool's reads and writes to `owner`.
182
+
183
+ **Assigning tools:** registering a tool makes it available to pick, not to call. Each assistant is given its own set (`assistant.tool_names`, a combobox on its edit screen); an assistant with none calls nothing.
184
+
185
+ **Calling context:** inside `#call`, `owner` is the record the conversation is scoped to (scope reads to it), `user` is the person doing the talking - the same record until an owner block scopes ownership elsewhere - plus `conversation` and `message`.
186
+
187
+ **The loop:** a response that asks for tools is not the end of the turn. `ToolRunnerService` runs the tools, records a `tool` role message per result, then queues a fresh assistant message so the model can answer with what came back. `max_tool_cycles` (default 10) bounds it. Results render as a collapsible `l-ui-surface` panel naming the tool, with input and output.
188
+
159
189
  ## Routes
160
190
 
161
191
  Mounted at `/layered/assistant` by default. Top-level resources: `personas`, `skills`, `assistants` (with nested `conversations`), `providers` (with nested `models`), `conversations` (with nested `messages` and a `stop` member route).
@@ -199,7 +229,7 @@ The engine renders inside `layered-ui-rails` layouts and uses only `l-ui-` class
199
229
  - **Provider creation fails with encryption error** - run `bin/rails db:encryption:init` and add the keys to credentials, or set `Layered::Assistant.skip_db_encryption = true` for dev/test.
200
230
  - **Panel body never loads** - `turbo-rails` must be installed and `layered-ui-rails` must be mounted in the layout. Check `import "@hotwired/turbo-rails"` is present.
201
231
  - **`layered_assistant` JS controllers missing** - ensure `import "layered_assistant"` is in `app/javascript/application.js` (added by the install generator, after the `layered_ui` import).
202
- - **Cross-tenant records visible** - configure `Layered::Assistant.scope` to filter by `owner`.
232
+ - **No records visible, or `MissingOwnerError` on create** - `current_owner` is nil. Check the authorize block only admits signed-in users, and that any `Layered::Assistant.owner` block returns a record.
203
233
 
204
234
  ## Further reference
205
235
 
data/NOTICE CHANGED
@@ -5,3 +5,23 @@ Copyright 2026 LAYERED AI LIMITED (UK company number: 17056830).
5
5
  This product includes software developed by LAYERED AI LIMITED and contributors.
6
6
 
7
7
  Licensed under the Apache License, Version 2.0.
8
+
9
+ ------------------------------------------------------------------------
10
+ Third-party software
11
+ ------------------------------------------------------------------------
12
+
13
+ This product bundles the following third-party JavaScript libraries
14
+ under app/javascript/layered_assistant/vendor/. Their full license
15
+ texts are included alongside the source files.
16
+
17
+ marked (https://github.com/markedjs/marked)
18
+ Copyright (c) 2018+ MarkedJS
19
+ Copyright (c) 2011-2018 Christopher Jeffrey
20
+ Licensed under the MIT License.
21
+ See: app/javascript/layered_assistant/vendor/marked.LICENSE
22
+
23
+ DOMPurify (https://github.com/cure53/DOMPurify)
24
+ Copyright Dr.-Ing. Mario Heiderich, Cure53
25
+ Dual-licensed under the Apache License 2.0 or the
26
+ Mozilla Public License 2.0; used here under Apache-2.0.
27
+ See: app/javascript/layered_assistant/vendor/dompurify.LICENSE
data/README.md CHANGED
@@ -120,31 +120,45 @@ The `l_assistant_accessible?` helper evaluates the authorize block without side
120
120
 
121
121
  ## Record scoping
122
122
 
123
- By default, all records are visible to any authorised user. If your application is multi-tenant or you need to restrict which records a user can see, configure a `scope` block in the initialiser.
123
+ Engine records are owned. Assistants, conversations, personas, providers and skills are stamped with an owner on create and filtered by it on read, so users only see their own records. The owner defaults to the signed-in user, so multi-tenant apps need no configuration.
124
124
 
125
- The block receives the model class, runs in controller context, and must return an `ActiveRecord::Relation`. All engine models with a polymorphic `owner` association are passed through the scope block.
125
+ ### Changing the ownership boundary
126
126
 
127
- ### Scope all owned resources to the current user
127
+ To scope records to something other than the signed-in user - their organisation, say - configure an `owner` block in the initialiser:
128
128
 
129
129
  ```ruby
130
- Layered::Assistant.scope do |model_class|
131
- model_class.where(owner: current_user)
130
+ Layered::Assistant.owner do
131
+ current_user.organisation
132
132
  end
133
133
  ```
134
134
 
135
- ### Scope conversations only
135
+ The block runs in controller context. Whatever it returns is used as the owner for both reads and creates.
136
+
137
+ ### When there is no owner
138
+
139
+ Reads return no records, and create actions raise `Layered::Assistant::MissingOwnerError` rather than persisting a record that every scoped read would then hide. Make sure your authorize block only admits authenticated users.
140
+
141
+ If you configure an owner block, it must return a record for every request your authorize block admits - not just for signed-in users. `current_user.organisation` returns nil for someone who has not created an organisation yet, and that user will hit `MissingOwnerError` on their first create. Either give the block a fallback, or have your authorize block send those users somewhere to set one up first.
142
+
143
+ Ownership is enforced at the controller layer, not by model validations. Out-of-scope IDs return 404.
144
+
145
+ ### The conversation user
146
+
147
+ Ownership answers "which records may this request see". It does not answer
148
+ "who is doing the talking" - and once an owner block scopes records to an
149
+ organisation, the owner cannot: every member of that organisation shares it.
150
+
151
+ So a conversation separately records the signed-in user who started it:
136
152
 
137
153
  ```ruby
138
- Layered::Assistant.scope do |model_class|
139
- if model_class == Layered::Assistant::Conversation
140
- model_class.where(owner: current_user)
141
- else
142
- model_class.all
143
- end
144
- end
154
+ conversation.owner # => #<Organisation id: 3> the boundary records are scoped to
155
+ conversation.user # => #<User id: 91> the person who asked
145
156
  ```
146
157
 
147
- When no scope block is configured, queries are unscoped. Record-level access control is the host application's responsibility; the scope block is the integration point for it.
158
+ The two are the same record until you configure an owner block. `user` is
159
+ always whoever was signed in and is never redirected by a block, and it is nil
160
+ for an anonymous visitor on a public assistant. Tools read both - see
161
+ [Tools](#tools).
148
162
 
149
163
  ## Panel helpers
150
164
 
@@ -173,6 +187,140 @@ Both helpers accept keyword arguments that are forwarded as HTML attributes to t
173
187
  | `layered_assistant_panel_header` | Empty Turbo Frame (`assistant_panel_header`) populated by the engine's panel views |
174
188
  | `layered_assistant_panel_body` | Turbo Frame (`assistant_panel`) that loads the conversation list from the engine's panel routes |
175
189
 
190
+ ## Tools
191
+
192
+ An assistant can call into the host application to fetch or change something
193
+ before it answers. Define a tool as a class in `app/tools`:
194
+
195
+ ```ruby
196
+ # app/tools/weather_tool.rb
197
+ class WeatherTool < Layered::Assistant::Tool
198
+ description "Get the current weather for a city."
199
+
200
+ argument :city, :string, required: true, description: "The city to look up."
201
+ argument :units, :string, description: "celsius or fahrenheit", enum: %w[celsius fahrenheit]
202
+
203
+ def call(city:, units: "celsius")
204
+ forecast = Weather.for(city)
205
+
206
+ { city: city, temperature: forecast.temperature(units), summary: forecast.summary }
207
+ end
208
+ end
209
+ ```
210
+
211
+ Then list the tool classes in your initialiser:
212
+
213
+ ```ruby
214
+ Layered::Assistant.tools do
215
+ [ WeatherTool ]
216
+ end
217
+ ```
218
+
219
+ The block is called per request rather than read once at boot, so tool classes
220
+ reload in development like any other application class.
221
+
222
+ ### Giving tools to an assistant
223
+
224
+ Registering a tool makes it available to pick, not available to call. Each
225
+ assistant is given its own set on its edit screen, and an assistant with no
226
+ tools calls nothing - so adding a tool to the application does not hand it to
227
+ every assistant at once. Two assistants can share a registered tool and still
228
+ be given different sets:
229
+
230
+ | Assistant | Tools |
231
+ |---|---|
232
+ | Sales assistant | `weather` |
233
+ | Support assistant | `weather`, `order-lookup` |
234
+
235
+ The set is held by tool name rather than a foreign key, because tools are
236
+ classes rather than records. A name whose class is no longer registered is
237
+ ignored, so removing a tool from the initialiser does not break the assistants
238
+ that listed it.
239
+
240
+ Outside the UI, assign the set with `tool_names`:
241
+
242
+ ```ruby
243
+ assistant.update!(tool_names: [ "weather", "order-lookup" ])
244
+ ```
245
+
246
+ ### Defining a tool
247
+
248
+ | Method | Description |
249
+ |---|---|
250
+ | `description` | What the tool does, in the model's words. This is the only thing the model has to go on, so be specific |
251
+ | `argument` | An argument the model may supply: `argument :name, :type, required:, description:, enum:, items:` |
252
+ | `tool_name` | The name the model calls the tool by. Defaults to the class name without its `Tool` suffix, namespaces hyphenated: `Weather::ForecastTool` becomes `weather-forecast` |
253
+ | `self.public =` | Whether the tool may be offered to a public assistant. `false` by default - see below |
254
+
255
+ Argument types are `:string`, `:integer`, `:number`, `:boolean`, `:array` and
256
+ `:object`. An `:array` takes `items:` to name its element type.
257
+
258
+ Subclass a tool to share logic and the declarations come with it: the child
259
+ inherits its parent's `description`, `public` flag and arguments, adds any
260
+ arguments of its own, and may redeclare one by name to narrow it. The name is
261
+ the exception - the child derives its own from its class name, since two tools
262
+ answering to one name would collide in the registry.
263
+
264
+ `#call` receives the arguments as keywords and may return a string or anything
265
+ that responds to `#to_json`. Raising is safe: the error is reported back to the
266
+ model as the tool's result, so it can correct itself or explain, rather than
267
+ the response failing.
268
+
269
+ Inside `#call`, four methods give the calling context:
270
+
271
+ | Method | Description |
272
+ |---|---|
273
+ | `owner` | The record the conversation is scoped to. Scope the tool's reads and writes to this - it is the caller's boundary |
274
+ | `user` | The person doing the talking. The same record as `owner` until an owner block scopes ownership elsewhere, at which point `owner` is (say) the organisation and `user` is the member of it who asked |
275
+ | `conversation` | The conversation the call came from |
276
+ | `message` | The assistant message that asked for the call |
277
+
278
+ Registering a tool does not scope it. `owner` is handed to you, but nothing
279
+ enforces that you use it - a tool that queries across every tenant will do
280
+ exactly that. Scoping is the tool's own job:
281
+
282
+ ```ruby
283
+ def call(reference:)
284
+ owner.orders.find_by(reference: reference)
285
+ end
286
+ ```
287
+
288
+ ### Tools and public assistants
289
+
290
+ A conversation with a public assistant has no owner - it belongs to an
291
+ anonymous visitor, so there is no boundary to scope a tool's reads to. Tools
292
+ are private by default and withheld from those conversations even when the
293
+ assistant has been given them. A tool that is safe to expose opts in, using
294
+ the same word an assistant does:
295
+
296
+ ```ruby
297
+ class CurrentTimeTool < Layered::Assistant::Tool
298
+ description "Get the current date and time on the server."
299
+ self.public = true
300
+
301
+ def call
302
+ { time: Time.current.iso8601 }
303
+ end
304
+ end
305
+ ```
306
+
307
+ This is written as an attribute rather than a `public true` DSL because
308
+ `public` is Ruby's own method-visibility keyword. A class method of that name
309
+ would shadow it, and a tool whose body used a bare `public` to reopen
310
+ visibility would silently mark itself callable by anonymous visitors - too
311
+ sharp an edge for a flag that governs exposure.
312
+
313
+ ### The tool call loop
314
+
315
+ A response that asks for tools is not the end of the turn. The engine runs the
316
+ tools, records a message per result, then queues a fresh assistant message so
317
+ the model can answer with what came back - which may ask for tools again. The
318
+ composer stays disabled until a response completes without asking for
319
+ anything, and `max_tool_cycles` (default 10) bounds the loop.
320
+
321
+ Results are shown in the conversation as a collapsible panel naming the tool,
322
+ with its input and output.
323
+
176
324
  ## Configuration
177
325
 
178
326
  Optional settings can be added to your initialiser (`config/initializers/layered_assistant.rb`):
@@ -188,6 +336,10 @@ Layered::Assistant.api_request_timeout = 210
188
336
  # Disable Active Record Encryption on Provider#secret.
189
337
  # Only use this in development/test environments without encryption keys configured.
190
338
  Layered::Assistant.skip_db_encryption = true
339
+
340
+ # How many rounds of tool calls one prompt may trigger before the engine gives
341
+ # up and says so (default: 10).
342
+ Layered::Assistant.max_tool_cycles = 10
191
343
  ```
192
344
 
193
345
  Note: `skip_db_encryption` is read at class load time, so it must be set before `Layered::Assistant::Provider` is first loaded. A standard Rails initialiser satisfies this requirement.
@@ -10,6 +10,51 @@ module Layered
10
10
 
11
11
  private
12
12
 
13
+ # The record ownership is stamped with on create and filtered by on
14
+ # reads. Configure an owner block in the initialiser to scope records
15
+ # to something other than the signed-in user (e.g. their organisation).
16
+ def current_owner
17
+ block = Layered::Assistant.owner_block
18
+
19
+ block ? instance_exec(&block) : l_ui_current_user
20
+ end
21
+
22
+ # The person a new conversation records as the one talking. Unlike the
23
+ # owner this is never redirected by a block: it is always whoever is
24
+ # signed in, and nil for an anonymous visitor on a public assistant.
25
+ def current_conversation_user
26
+ l_ui_current_user
27
+ end
28
+
29
+ # Owner stamping on create goes through this bang variant: persisting
30
+ # a record with a nil owner would leave it invisible to every scoped
31
+ # read, so a missing owner fails loudly instead.
32
+ def current_owner!
33
+ current_owner || raise(MissingOwnerError, "current_owner is nil - the authorize block admitted a request without a signed-in user, or the owner block returned nil")
34
+ end
35
+
36
+ def scoped(model_class)
37
+ model_class.owned_by(current_owner)
38
+ end
39
+
40
+ # Models carry no owner of their own - they belong to the owner of
41
+ # their provider - so the set a request may choose from is filtered
42
+ # through the provider rather than through `scoped`.
43
+ def scoped_models
44
+ Model.available.merge(scoped(Provider))
45
+ end
46
+
47
+ # The composer posts the model to answer with, so the id has to be
48
+ # resolved through `scoped_models` before it reaches a message -
49
+ # otherwise a request could name another owner's model and have the
50
+ # response generated through their provider, on their API key. An
51
+ # out-of-scope id 404s, as it does everywhere else.
52
+ def scoped_model_id(model_id)
53
+ return nil if model_id.blank?
54
+
55
+ scoped_models.find(model_id).id
56
+ end
57
+
13
58
  def layered_assistant_authorize!
14
59
  block = Layered::Assistant.authorize_block
15
60
 
@@ -20,18 +65,6 @@ module Layered
20
65
 
21
66
  instance_exec(&block)
22
67
  end
23
-
24
- def scoped(model_class)
25
- block = Layered::Assistant.scope_block
26
- return model_class.all unless block
27
-
28
- result = instance_exec(model_class, &block)
29
- unless result.is_a?(ActiveRecord::Relation)
30
- raise ArgumentError,
31
- "Layered::Assistant.scope must return an ActiveRecord::Relation, got #{result.class}"
32
- end
33
- result
34
- end
35
68
  end
36
69
  end
37
70
  end
@@ -1,83 +1,64 @@
1
1
  module Layered
2
2
  module Assistant
3
- class AssistantsController < ApplicationController
4
- before_action :set_assistant, only: [:edit, :update, :destroy]
5
- before_action :set_models, only: [:new, :create, :edit, :update]
6
- before_action :set_personas, only: [:new, :create, :edit, :update]
7
- before_action :set_skills, only: [:new, :create, :edit, :update]
3
+ # Assistants point at a model, a persona, a set of skills and a set of
4
+ # tools. All but the tools are owner-scoped records, and the resource
5
+ # class cannot scope them itself - a field's `collection:` is resolved
6
+ # without a controller - so the options are filled in here, and the ids
7
+ # that come back are resolved through `scoped` before they reach the
8
+ # record.
9
+ #
10
+ # `@fields` is set by the gem's own `load_layered_resource`, declared
11
+ # when `Layered::Resource::Controller` is included by ResourcesController,
12
+ # so it has always run by the time this subclass's callback does.
13
+ class AssistantsController < ResourcesController
14
+ before_action :scope_choice_fields, only: [ :new, :create, :edit, :update ]
8
15
 
9
- def index
10
- @page_title = "Assistants"
11
- @pagy, @assistants = pagy(scoped(Assistant).includes(:persona).by_name)
12
- end
13
-
14
- def new
15
- @page_title = "New assistant"
16
- @assistant = Assistant.new
17
- end
16
+ private
18
17
 
19
- def create
20
- @assistant = Assistant.new(assistant_params.except(:persona_id, :skill_ids))
21
- @assistant.owner = l_ui_current_user
22
- @assistant.persona = scoped(Persona).find(assistant_params[:persona_id]) if assistant_params[:persona_id].present?
18
+ def scope_choice_fields
19
+ models = scoped_models.map { |model| [ "#{model.provider.name} - #{model.name}", model.id ] }
20
+ personas = scoped(Persona).by_name.map { |persona| [ persona.name, persona.id ] }
21
+ skills = scoped(Skill).by_name.map { |skill| [ skill.name, skill.id ] }
22
+ # Tools are classes the host application registered, not records, so
23
+ # there is nothing to scope - the registry is the whole list.
24
+ tools = ToolRegistry.all.map { |tool| [ tool.tool_name, tool.tool_name ] }
23
25
 
24
- if @assistant.save
25
- assign_skills
26
- redirect_to layered_assistant.assistants_path, notice: "Assistant was successfully created."
27
- else
28
- render :new, status: :unprocessable_entity
26
+ @fields = @fields.map do |field|
27
+ case field[:attribute]
28
+ when :default_model_id then field.merge(collection: models)
29
+ when :persona_id then field.merge(collection: personas)
30
+ when :skill_ids then field.merge(collection: skills)
31
+ when :tool_names then field.merge(collection: tools)
32
+ else field
33
+ end
29
34
  end
30
35
  end
31
36
 
32
- def edit
33
- @page_title = "Edit assistant"
34
- end
37
+ # An out-of-scope model or persona 404s rather than being silently
38
+ # dropped, matching how a record itself is looked up. Skills and tools
39
+ # are filtered instead: the picker posts a list, and one stale entry
40
+ # should not fail the whole save.
41
+ def layered_resource_params
42
+ attributes = super
35
43
 
36
- def update
37
- if assistant_params.key?(:persona_id)
38
- @assistant.persona = assistant_params[:persona_id].present? ? scoped(Persona).find(assistant_params[:persona_id]) : nil
44
+ if attributes[:default_model_id].present?
45
+ attributes[:default_model_id] = scoped_models.find(attributes[:default_model_id]).id
39
46
  end
40
47
 
41
- if @assistant.update(assistant_params.except(:persona_id, :skill_ids))
42
- assign_skills
43
- redirect_to layered_assistant.assistants_path, notice: "Assistant was successfully updated."
44
- else
45
- render :edit, status: :unprocessable_entity
48
+ if attributes[:persona_id].present?
49
+ attributes[:persona_id] = scoped(Persona).find(attributes[:persona_id]).id
46
50
  end
47
- end
48
-
49
- def destroy
50
- @assistant.destroy
51
- redirect_to layered_assistant.assistants_path, notice: "Assistant was successfully deleted."
52
- end
53
-
54
- private
55
51
 
56
- def set_assistant
57
- @assistant = scoped(Assistant).find(params[:id])
58
- end
59
-
60
- def set_models
61
- @models = Model.available
62
- end
63
-
64
- def set_personas
65
- @personas = scoped(Persona).by_name
66
- end
67
-
68
- def set_skills
69
- @skills = scoped(Skill).by_name
70
- end
52
+ if attributes.key?(:skill_ids)
53
+ attributes[:skill_ids] = scoped(Skill).where(id: Array(attributes[:skill_ids]).compact_blank).ids
54
+ end
71
55
 
72
- def assign_skills
73
- if assistant_params.key?(:skill_ids)
74
- skill_ids = Array(assistant_params[:skill_ids]).compact_blank
75
- @assistant.skills = scoped(Skill).where(id: skill_ids)
56
+ if attributes.key?(:tool_names)
57
+ registered = ToolRegistry.all.map(&:tool_name)
58
+ attributes[:tool_names] = Array(attributes[:tool_names]).compact_blank & registered
76
59
  end
77
- end
78
60
 
79
- def assistant_params
80
- params.require(:assistant).permit(:name, :description, :instructions, :default_model_id, :persona_id, :public, skill_ids: [])
61
+ attributes
81
62
  end
82
63
  end
83
64
  end
@@ -3,39 +3,40 @@ module Layered
3
3
  class ConversationsController < ApplicationController
4
4
  include StoppableResponse
5
5
 
6
- before_action :set_conversation, only: [:show, :edit, :update, :destroy, :stop]
7
- before_action :set_assistants, only: [:new, :create]
6
+ before_action :set_conversation, only: [ :show, :edit, :update, :destroy, :stop ]
7
+ before_action :set_assistants, only: [ :new, :create ]
8
8
 
9
9
  def index
10
10
  if params[:assistant_id]
11
11
  @assistant = scoped(Assistant).find(params[:assistant_id])
12
12
  @page_title = "Conversations - #{@assistant.name}"
13
- @pagy, @conversations = pagy(@assistant.conversations.merge(scoped(Conversation)).includes(:owner).by_created_at)
13
+ @pagy, @conversations = pagy(@assistant.conversations.merge(scoped(Conversation)).includes(:owner, :user).by_created_at)
14
14
  else
15
15
  @page_title = "Conversations"
16
- @pagy, @conversations = pagy(scoped(Conversation).includes(:assistant, :owner).by_created_at)
16
+ @pagy, @conversations = pagy(scoped(Conversation).includes(:assistant, :owner, :user).by_created_at)
17
17
  end
18
18
  end
19
19
 
20
20
  def show
21
21
  @page_title = @conversation.name
22
22
  @messages = @conversation.messages.includes(:model).by_created_at
23
- @models = Model.available
23
+ @models = scoped_models
24
24
  @selected_model_id = @messages.last&.model_id || @conversation.assistant.default_model_id || @models.first&.id
25
25
  end
26
26
 
27
27
  def new
28
28
  @page_title = "New conversation"
29
- @conversation = Conversation.new(params.permit(conversation: [:assistant_id])[:conversation])
29
+ @conversation = Conversation.new(params.permit(conversation: [ :assistant_id ])[:conversation])
30
30
  end
31
31
 
32
32
  def create
33
33
  @conversation = Conversation.new(conversation_params)
34
- @conversation.owner = l_ui_current_user
34
+ @conversation.owner = current_owner!
35
+ @conversation.user = current_conversation_user
35
36
  @conversation.assistant = scoped(Assistant).find(conversation_params[:assistant_id]) if conversation_params[:assistant_id].present?
36
37
  @conversation.name = Conversation.default_name if @conversation.name.blank?
37
38
  if @conversation.save
38
- redirect_to layered_assistant.conversation_path(@conversation), notice: "Conversation was successfully created."
39
+ redirect_to layered_assistant.conversation_path(@conversation)
39
40
  else
40
41
  render :new, status: :unprocessable_entity
41
42
  end
@@ -4,7 +4,7 @@ module Layered
4
4
  include MessageCreation
5
5
 
6
6
  before_action :set_conversation
7
- before_action :set_message, only: [:destroy]
7
+ before_action :set_message, only: [ :destroy ]
8
8
 
9
9
  def index
10
10
  @page_title = "Messages"
@@ -12,10 +12,12 @@ module Layered
12
12
  end
13
13
 
14
14
  def create
15
+ model_id = scoped_model_id(message_params[:model_id])
16
+
15
17
  result = create_messages_for(
16
18
  conversation: @conversation,
17
19
  content: message_params[:content],
18
- model_id: message_params[:model_id]
20
+ model_id: model_id
19
21
  )
20
22
  @message = result[:message]
21
23
 
@@ -24,8 +26,8 @@ module Layered
24
26
  end
25
27
 
26
28
  @assistant_message = result[:assistant_message]
27
- @models = Model.available
28
- @selected_model_id = message_params[:model_id]
29
+ @models = scoped_models
30
+ @selected_model_id = model_id
29
31
  @error = result[:error]
30
32
 
31
33
  respond_to do |format|