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.
- checksums.yaml +4 -4
- data/.claude/skills/layered-assistant-rails/SKILL.md +46 -16
- data/NOTICE +20 -0
- data/README.md +166 -14
- data/app/controllers/layered/assistant/application_controller.rb +45 -12
- data/app/controllers/layered/assistant/assistants_controller.rb +45 -64
- data/app/controllers/layered/assistant/conversations_controller.rb +9 -8
- data/app/controllers/layered/assistant/messages_controller.rb +6 -4
- data/app/controllers/layered/assistant/panel/conversations_controller.rb +6 -5
- data/app/controllers/layered/assistant/panel/messages_controller.rb +5 -3
- data/app/controllers/layered/assistant/providers_controller.rb +6 -48
- data/app/controllers/layered/assistant/public/assistants_controller.rb +1 -1
- data/app/controllers/layered/assistant/public/conversations_controller.rb +3 -3
- data/app/controllers/layered/assistant/public/panel/conversations_controller.rb +4 -4
- data/app/controllers/layered/assistant/resources_controller.rb +10 -0
- data/app/helpers/layered/assistant/messages_helper.rb +27 -87
- data/app/javascript/layered_assistant/composer_controller.js +0 -1
- data/app/javascript/layered_assistant/index.js +2 -0
- data/app/javascript/layered_assistant/markdown_controller.js +19 -0
- data/app/javascript/layered_assistant/marked_setup.js +20 -0
- data/app/javascript/layered_assistant/message_streaming.js +35 -53
- data/app/javascript/layered_assistant/messages_controller.js +1 -1
- data/app/javascript/layered_assistant/vendor/dompurify.LICENSE +568 -0
- data/app/javascript/layered_assistant/vendor/dompurify.js +1471 -0
- data/app/javascript/layered_assistant/vendor/marked.LICENSE +44 -0
- data/app/javascript/layered_assistant/vendor/marked.js +8 -0
- data/app/jobs/layered/assistant/messages/response_job.rb +11 -2
- data/app/layered_resources/layered/assistant/assistant_resource.rb +57 -0
- data/app/layered_resources/layered/assistant/model_resource.rb +36 -0
- data/app/layered_resources/layered/assistant/persona_resource.rb +35 -0
- data/app/layered_resources/layered/assistant/provider_resource.rb +44 -0
- data/app/layered_resources/layered/assistant/skill_resource.rb +34 -0
- data/app/models/concerns/layered/assistant/ownable.rb +13 -0
- data/app/models/layered/assistant/assistant.rb +22 -1
- data/app/models/layered/assistant/assistant_tool.rb +18 -0
- data/app/models/layered/assistant/conversation.rb +8 -1
- data/app/models/layered/assistant/message.rb +10 -23
- data/app/models/layered/assistant/persona.rb +2 -1
- data/app/models/layered/assistant/provider.rb +9 -2
- data/app/models/layered/assistant/skill.rb +2 -1
- data/app/services/layered/assistant/chunk_parser.rb +70 -1
- data/app/services/layered/assistant/chunk_service.rb +55 -9
- data/app/services/layered/assistant/client_service.rb +3 -1
- data/app/services/layered/assistant/clients/anthropic.rb +2 -1
- data/app/services/layered/assistant/clients/base.rb +1 -1
- data/app/services/layered/assistant/clients/openai.rb +10 -9
- data/app/services/layered/assistant/messages_service.rb +52 -5
- data/app/services/layered/assistant/tool_call_accumulator.rb +37 -0
- data/app/services/layered/assistant/tool_definitions_service.rb +36 -0
- data/app/services/layered/assistant/tool_registry.rb +33 -0
- data/app/services/layered/assistant/tool_runner_service.rb +135 -0
- data/app/tools/layered/assistant/tool.rb +174 -0
- data/app/views/layered/assistant/conversations/_form.html.erb +3 -3
- data/app/views/layered/assistant/conversations/index.html.erb +11 -9
- data/app/views/layered/assistant/conversations/show.html.erb +2 -2
- data/app/views/layered/assistant/messages/_composer_fields.html.erb +3 -3
- data/app/views/layered/assistant/messages/_message.html.erb +28 -25
- data/app/views/layered/assistant/messages/_tool_message.html.erb +19 -0
- data/app/views/layered/assistant/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/messages/index.html.erb +6 -6
- data/app/views/layered/assistant/panel/conversations/_header.html.erb +3 -3
- data/app/views/layered/assistant/panel/conversations/index.html.erb +6 -6
- data/app/views/layered/assistant/panel/conversations/new.html.erb +4 -4
- data/app/views/layered/assistant/panel/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/providers/new.html.erb +63 -5
- data/app/views/layered/assistant/public/assistants/index.html.erb +3 -3
- data/app/views/layered/assistant/public/conversations/show.html.erb +3 -3
- data/app/views/layered/assistant/public/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/public/panel/conversations/_header.html.erb +3 -3
- data/app/views/layered/assistant/public/panel/conversations/index.html.erb +5 -5
- data/app/views/layered/assistant/public/panel/conversations/new.html.erb +2 -2
- data/app/views/layered/assistant/public/panel/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/setup/_setup.html.erb +93 -74
- data/config/importmap.rb +4 -0
- data/config/routes.rb +17 -15
- data/data/models.json +37 -24
- data/db/migrate/20260804000000_add_resolved_model_to_layered_assistant_messages.rb +5 -0
- data/db/migrate/20260831000000_add_tool_calling_to_layered_assistant_messages.rb +11 -0
- data/db/migrate/20260901000000_create_layered_assistant_assistant_tools.rb +13 -0
- data/db/migrate/20260901000001_add_user_to_layered_assistant_conversations.rb +5 -0
- data/lib/generators/layered/assistant/templates/initializer.rb +56 -18
- data/lib/layered/assistant/version.rb +1 -1
- data/lib/layered/assistant.rb +14 -5
- metadata +45 -43
- data/app/controllers/layered/assistant/models_controller.rb +0 -61
- data/app/controllers/layered/assistant/personas_controller.rb +0 -60
- data/app/controllers/layered/assistant/skills_controller.rb +0 -60
- data/app/views/layered/assistant/assistants/_form.html.erb +0 -61
- data/app/views/layered/assistant/assistants/edit.html.erb +0 -6
- data/app/views/layered/assistant/assistants/index.html.erb +0 -52
- data/app/views/layered/assistant/assistants/new.html.erb +0 -6
- data/app/views/layered/assistant/models/_form.html.erb +0 -30
- data/app/views/layered/assistant/models/edit.html.erb +0 -9
- data/app/views/layered/assistant/models/index.html.erb +0 -53
- data/app/views/layered/assistant/models/new.html.erb +0 -9
- data/app/views/layered/assistant/personas/_form.html.erb +0 -31
- data/app/views/layered/assistant/personas/edit.html.erb +0 -6
- data/app/views/layered/assistant/personas/index.html.erb +0 -40
- data/app/views/layered/assistant/personas/new.html.erb +0 -6
- data/app/views/layered/assistant/providers/_form.html.erb +0 -81
- data/app/views/layered/assistant/providers/edit.html.erb +0 -6
- data/app/views/layered/assistant/providers/index.html.erb +0 -46
- data/app/views/layered/assistant/skills/_form.html.erb +0 -30
- data/app/views/layered/assistant/skills/edit.html.erb +0 -6
- data/app/views/layered/assistant/skills/index.html.erb +0 -40
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0849058555cc1dd6e7a2dffb73b3919086413374156a1c3300999eaa543c8a4b'
|
|
4
|
+
data.tar.gz: fa46c8ac4e95587216f467be30eb25d8c01c427d642b589b63a0ab849984e4fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
74
|
-
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
125
|
+
### Changing the ownership boundary
|
|
126
126
|
|
|
127
|
-
|
|
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.
|
|
131
|
-
|
|
130
|
+
Layered::Assistant.owner do
|
|
131
|
+
current_user.organisation
|
|
132
132
|
end
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
42
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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)
|
|
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:
|
|
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 =
|
|
28
|
-
@selected_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|
|