layered-assistant-rails 0.7.0 → 0.8.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/README.md +79 -3
- data/app/controllers/concerns/layered/assistant/message_creation.rb +17 -1
- data/app/controllers/layered/assistant/messages_controller.rb +3 -2
- data/app/controllers/layered/assistant/panel/messages_controller.rb +3 -2
- data/app/controllers/layered/assistant/public/messages_controller.rb +3 -3
- data/app/controllers/layered/assistant/public/panel/messages_controller.rb +3 -3
- data/app/controllers/layered/assistant/tool_calls_controller.rb +34 -0
- data/app/javascript/layered_assistant/composer_controller.js +19 -4
- data/app/javascript/layered_assistant/message_streaming.js +9 -0
- data/app/jobs/layered/assistant/messages/tool_call_job.rb +22 -0
- data/app/models/layered/assistant/conversation.rb +63 -0
- data/app/models/layered/assistant/message.rb +68 -1
- data/app/services/layered/assistant/tool_runner_service.rb +135 -6
- data/app/tools/layered/assistant/tool.rb +88 -1
- data/app/views/layered/assistant/messages/_composer.html.erb +1 -1
- data/app/views/layered/assistant/messages/_tool_message.html.erb +39 -4
- data/app/views/layered/assistant/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/panel/messages/_composer.html.erb +1 -1
- data/app/views/layered/assistant/panel/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/public/messages/_composer.html.erb +1 -1
- data/app/views/layered/assistant/public/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/public/panel/messages/_composer.html.erb +1 -1
- data/app/views/layered/assistant/public/panel/messages/create.turbo_stream.erb +1 -1
- data/config/routes.rb +6 -0
- data/db/migrate/20260919000000_add_tool_status_to_layered_assistant_messages.rb +8 -0
- data/lib/generators/layered/assistant/templates/initializer.rb +28 -0
- data/lib/layered/assistant/version.rb +1 -1
- data/lib/layered/assistant.rb +12 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c4266f3d5e33f449619fe77a6e9b0819bcc22c038ffe3498c96ea56549faa80
|
|
4
|
+
data.tar.gz: 6b4e8a671a99e0e0cfd987b631dc316616743f5b3dbe3b2d3370d8c0a7f1a614
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0681356f7a030b95dcc62e0b589647b4e4c650fa1683dbf2b98ddceefd89cb74ea1b73578053de064c32420faefd21c2bd28cd7ef5ffc1660d39cd991d954713'
|
|
7
|
+
data.tar.gz: df9c6fde84b9897e4833fd7beedcb0557c319f6603b663af10f9fa1b4ce1c69ddf4041dd9aa7aea335c09b2b26764aecf53fdbb3ac21d3871796f11fdcc0bc28
|
data/README.md
CHANGED
|
@@ -251,13 +251,15 @@ assistant.update!(tool_names: [ "weather", "order-lookup" ])
|
|
|
251
251
|
| `argument` | An argument the model may supply: `argument :name, :type, required:, description:, enum:, items:` |
|
|
252
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
253
|
| `self.public =` | Whether the tool may be offered to a public assistant. `false` by default - see below |
|
|
254
|
+
| `permit` | A block deciding which conversations may call the tool - see [Who may call a tool](#who-may-call-a-tool) |
|
|
255
|
+
| `consent` | `:always` to put each call to the person talking before it runs. `:never` by default - see [Asking before a tool runs](#asking-before-a-tool-runs) |
|
|
254
256
|
|
|
255
257
|
Argument types are `:string`, `:integer`, `:number`, `:boolean`, `:array` and
|
|
256
258
|
`:object`. An `:array` takes `items:` to name its element type.
|
|
257
259
|
|
|
258
260
|
Subclass a tool to share logic and the declarations come with it: the child
|
|
259
|
-
inherits its parent's `description`, `public` flag
|
|
260
|
-
arguments of its own, and may redeclare one by name to narrow it. The name is
|
|
261
|
+
inherits its parent's `description`, `public` flag, `permit` block, `consent`
|
|
262
|
+
setting and arguments, adds any arguments of its own, and may redeclare one by name to narrow it. The name is
|
|
261
263
|
the exception - the child derives its own from its class name, since two tools
|
|
262
264
|
answering to one name would collide in the registry.
|
|
263
265
|
|
|
@@ -285,6 +287,79 @@ def call(reference:)
|
|
|
285
287
|
end
|
|
286
288
|
```
|
|
287
289
|
|
|
290
|
+
### Who may call a tool
|
|
291
|
+
|
|
292
|
+
Which assistants have a tool is a configuration decision; who may call it is a
|
|
293
|
+
runtime one. A `permit` block narrows a tool to the conversations that satisfy
|
|
294
|
+
it:
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
class RefundTool < Layered::Assistant::Tool
|
|
298
|
+
description "Refund an order."
|
|
299
|
+
|
|
300
|
+
permit { |conversation| conversation.user&.admin? }
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
An unpermitted tool is left out of the definitions sent to the provider, so
|
|
305
|
+
the model is never told it exists, and is refused if it asks for it anyway
|
|
306
|
+
from an earlier turn's history. The block is inherited like the other
|
|
307
|
+
declarations, so a tool subclassed to share logic keeps its parent's policy
|
|
308
|
+
unless it declares its own.
|
|
309
|
+
|
|
310
|
+
To apply one rule across every tool, configure an `authorize_tool` block in
|
|
311
|
+
your initialiser. It is given the tool class and the conversation, and
|
|
312
|
+
returning falsey withholds the tool:
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
Layered::Assistant.authorize_tool do |tool, conversation|
|
|
316
|
+
conversation.user&.permitted_tools&.include?(tool.tool_name)
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Unlike `authorize`, which guards the engine's routes, this is left open when
|
|
321
|
+
unconfigured: tool calls already sit behind that block and behind the set of
|
|
322
|
+
tools each assistant has been given. A block that raises denies the tool and
|
|
323
|
+
logs, rather than handing it over or failing the response.
|
|
324
|
+
|
|
325
|
+
### Asking before a tool runs
|
|
326
|
+
|
|
327
|
+
Reads are usually fine unattended. Anything that writes, spends or sends is
|
|
328
|
+
worth putting to the person talking first:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
class RefundTool < Layered::Assistant::Tool
|
|
332
|
+
description "Refund an order."
|
|
333
|
+
consent :always
|
|
334
|
+
|
|
335
|
+
argument :reference, :string, required: true
|
|
336
|
+
|
|
337
|
+
def call(reference:)
|
|
338
|
+
owner.orders.find_by!(reference: reference).refund!
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
The call is recorded in the conversation with its arguments shown and nothing
|
|
344
|
+
in it, and the response stops there - the composer stays disabled, and the
|
|
345
|
+
call waits as long as it needs to, surviving a reload. Approving runs the tool
|
|
346
|
+
and the response picks up where it left off. Declining reports the refusal to
|
|
347
|
+
the model as the tool's result, so it can say something useful about being
|
|
348
|
+
turned down rather than the conversation dead-ending. Stopping the response
|
|
349
|
+
declines whatever is outstanding, including a call that has been approved but
|
|
350
|
+
has yet to run - so a tool that writes, spends or sends does not slip through
|
|
351
|
+
after the Stop. A call whose tool is already running is left to finish, its
|
|
352
|
+
result recorded, and the response is still not picked back up.
|
|
353
|
+
|
|
354
|
+
Calls in the same batch that need no consent still run while one waits: each
|
|
355
|
+
call stands on its own.
|
|
356
|
+
|
|
357
|
+
A tool that asks for consent needs both a user and an owner, so it is never
|
|
358
|
+
offered to a public assistant - even one an anonymous visitor happens to be
|
|
359
|
+
signed in for. There is nobody to ask an anonymous visitor, and a decision is
|
|
360
|
+
recorded through the owner-scoped route a conversation with no owner cannot
|
|
361
|
+
reach. The tool is withheld rather than offered and then stuck waiting.
|
|
362
|
+
|
|
288
363
|
### Tools and public assistants
|
|
289
364
|
|
|
290
365
|
A conversation with a public assistant has no owner - it belongs to an
|
|
@@ -319,7 +394,8 @@ composer stays disabled until a response completes without asking for
|
|
|
319
394
|
anything, and `max_tool_cycles` (default 10) bounds the loop.
|
|
320
395
|
|
|
321
396
|
Results are shown in the conversation as a collapsible panel naming the tool,
|
|
322
|
-
with its input and output.
|
|
397
|
+
with its input and output. A call waiting to be approved holds the loop where
|
|
398
|
+
it is until it has been answered.
|
|
323
399
|
|
|
324
400
|
## Configuration
|
|
325
401
|
|
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
module Layered
|
|
2
2
|
module Assistant
|
|
3
3
|
module MessageCreation
|
|
4
|
+
WAITING_ON_TOOL_CALL = "A tool call is waiting on you. Answer it before sending another message.".freeze
|
|
5
|
+
|
|
4
6
|
private
|
|
5
7
|
|
|
6
8
|
def create_messages_for(conversation:, content:, model_id:)
|
|
9
|
+
# A tool call that has yet to reach an answer holds the conversation.
|
|
10
|
+
# The composer is disabled while it waits, so anything arriving here is
|
|
11
|
+
# a stale tab - and answering it would send the model a call with no
|
|
12
|
+
# result yet, which the provider rejects. Said rather than dropped, or
|
|
13
|
+
# the message would vanish with nothing to explain it.
|
|
14
|
+
if conversation.unresolved_tool_call?
|
|
15
|
+
return {
|
|
16
|
+
message: conversation.messages.new(role: :user, content: content),
|
|
17
|
+
error: WAITING_ON_TOOL_CALL,
|
|
18
|
+
responding: true
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
7
22
|
message = conversation.messages.create(
|
|
8
23
|
role: :user,
|
|
9
24
|
content: content,
|
|
@@ -37,7 +52,8 @@ module Layered
|
|
|
37
52
|
{
|
|
38
53
|
message: message,
|
|
39
54
|
assistant_message: assistant_message,
|
|
40
|
-
error: error
|
|
55
|
+
error: error,
|
|
56
|
+
responding: error.nil?
|
|
41
57
|
}
|
|
42
58
|
end
|
|
43
59
|
end
|
|
@@ -20,15 +20,16 @@ module Layered
|
|
|
20
20
|
model_id: model_id
|
|
21
21
|
)
|
|
22
22
|
@message = result[:message]
|
|
23
|
+
@error = result[:error]
|
|
24
|
+
@responding = result[:responding]
|
|
23
25
|
|
|
24
|
-
unless @message.persisted?
|
|
26
|
+
unless @message.persisted? || @error
|
|
25
27
|
return head :unprocessable_entity
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
@assistant_message = result[:assistant_message]
|
|
29
31
|
@models = scoped_models
|
|
30
32
|
@selected_model_id = model_id
|
|
31
|
-
@error = result[:error]
|
|
32
33
|
|
|
33
34
|
respond_to do |format|
|
|
34
35
|
format.turbo_stream
|
|
@@ -16,15 +16,16 @@ module Layered
|
|
|
16
16
|
model_id: model_id
|
|
17
17
|
)
|
|
18
18
|
@message = result[:message]
|
|
19
|
+
@error = result[:error]
|
|
20
|
+
@responding = result[:responding]
|
|
19
21
|
|
|
20
|
-
unless @message.persisted?
|
|
22
|
+
unless @message.persisted? || @error
|
|
21
23
|
return head :unprocessable_entity
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
@assistant_message = result[:assistant_message]
|
|
25
27
|
@models = scoped_models
|
|
26
28
|
@selected_model_id = model_id
|
|
27
|
-
@error = result[:error]
|
|
28
29
|
|
|
29
30
|
respond_to do |format|
|
|
30
31
|
format.turbo_stream
|
|
@@ -13,13 +13,13 @@ module Layered
|
|
|
13
13
|
model_id: @conversation.assistant.default_model_id
|
|
14
14
|
)
|
|
15
15
|
@message = result[:message]
|
|
16
|
+
@error = result[:error]
|
|
17
|
+
@responding = result[:responding]
|
|
16
18
|
|
|
17
|
-
unless @message.persisted?
|
|
19
|
+
unless @message.persisted? || @error
|
|
18
20
|
return head :unprocessable_entity
|
|
19
21
|
end
|
|
20
22
|
|
|
21
|
-
@error = result[:error]
|
|
22
|
-
|
|
23
23
|
respond_to do |format|
|
|
24
24
|
format.turbo_stream
|
|
25
25
|
end
|
|
@@ -15,13 +15,13 @@ module Layered
|
|
|
15
15
|
model_id: @conversation.assistant.default_model_id
|
|
16
16
|
)
|
|
17
17
|
@message = result[:message]
|
|
18
|
+
@error = result[:error]
|
|
19
|
+
@responding = result[:responding]
|
|
18
20
|
|
|
19
|
-
unless @message.persisted?
|
|
21
|
+
unless @message.persisted? || @error
|
|
20
22
|
return head :unprocessable_entity
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
@error = result[:error]
|
|
24
|
-
|
|
25
25
|
respond_to do |format|
|
|
26
26
|
format.turbo_stream
|
|
27
27
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Layered
|
|
2
|
+
module Assistant
|
|
3
|
+
# Approving or declining a tool call that asked for consent before it ran.
|
|
4
|
+
class ToolCallsController < ApplicationController
|
|
5
|
+
DECISIONS = { "approve" => "approved", "decline" => "declined" }.freeze
|
|
6
|
+
|
|
7
|
+
before_action :set_conversation
|
|
8
|
+
|
|
9
|
+
def update
|
|
10
|
+
status = DECISIONS[params[:decision]]
|
|
11
|
+
return head :unprocessable_entity unless status
|
|
12
|
+
|
|
13
|
+
message = @conversation.messages.find(params[:id])
|
|
14
|
+
|
|
15
|
+
# Conditional so that two decisions arriving at once cannot both win,
|
|
16
|
+
# and so a second click or a stale tab changes nothing.
|
|
17
|
+
decided = Message.where(id: message.id, tool_status: "pending")
|
|
18
|
+
.update_all(tool_status: status, updated_at: Time.current)
|
|
19
|
+
return head :no_content if decided.zero?
|
|
20
|
+
|
|
21
|
+
message.reload.broadcast_updated
|
|
22
|
+
Messages::ToolCallJob.perform_later(message.id)
|
|
23
|
+
|
|
24
|
+
head :no_content
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def set_conversation
|
|
30
|
+
@conversation = scoped(Conversation).find_by!(uid: params[:conversation_id])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -7,11 +7,15 @@ export default class extends Controller {
|
|
|
7
7
|
static targets = ["form", "input", "sendButton", "stopButton"]
|
|
8
8
|
static values = {
|
|
9
9
|
responding: { type: Boolean, default: false },
|
|
10
|
+
waiting: { type: Boolean, default: false },
|
|
10
11
|
stopUrl: { type: String, default: "" }
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
connect() {
|
|
14
|
-
this._onChunkReceived = () =>
|
|
15
|
+
this._onChunkReceived = () => {
|
|
16
|
+
this.waitingValue = false
|
|
17
|
+
this._resetRespondingTimeout()
|
|
18
|
+
}
|
|
15
19
|
document.addEventListener("assistant:chunk-received", this._onChunkReceived)
|
|
16
20
|
this._applyRespondingState()
|
|
17
21
|
this.updateButtonDisabled()
|
|
@@ -26,6 +30,16 @@ export default class extends Controller {
|
|
|
26
30
|
this._applyRespondingState()
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
// A tool call waiting to be approved sends nothing until it is answered,
|
|
34
|
+
// so the safety timeout is stood down rather than giving up on it.
|
|
35
|
+
waitingValueChanged() {
|
|
36
|
+
if (this.waitingValue) {
|
|
37
|
+
clearTimeout(this._respondingTimeout)
|
|
38
|
+
} else {
|
|
39
|
+
this._resetRespondingTimeout()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
// Enter submits, Shift+Enter is a no-op (default newline),
|
|
30
44
|
// Alt+Enter inserts a newline without submitting.
|
|
31
45
|
submitOnEnter(event) {
|
|
@@ -81,9 +95,10 @@ export default class extends Controller {
|
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
// Toggle visibility of the Send and Stop buttons. While responding a
|
|
84
|
-
//
|
|
98
|
+
// 30-second safety timeout resets the composer in case the server
|
|
85
99
|
// never signals completion. The timeout is reset each time a chunk
|
|
86
|
-
// is received so long-running responses are not interrupted
|
|
100
|
+
// is received so long-running responses are not interrupted, and is
|
|
101
|
+
// not armed at all while a tool call waits to be approved.
|
|
87
102
|
_applyRespondingState() {
|
|
88
103
|
clearTimeout(this._respondingTimeout)
|
|
89
104
|
|
|
@@ -104,7 +119,7 @@ export default class extends Controller {
|
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
_resetRespondingTimeout() {
|
|
107
|
-
if (!this.respondingValue) return
|
|
122
|
+
if (!this.respondingValue || this.waitingValue) return
|
|
108
123
|
clearTimeout(this._respondingTimeout)
|
|
109
124
|
this._respondingTimeout = setTimeout(() => {
|
|
110
125
|
this.respondingValue = false
|
|
@@ -18,10 +18,19 @@ const pendingRender = new WeakMap()
|
|
|
18
18
|
|
|
19
19
|
Turbo.StreamActions.enable_composer = function () {
|
|
20
20
|
this.targetElements.forEach((form) => {
|
|
21
|
+
form.setAttribute("data-composer-waiting-value", "false")
|
|
21
22
|
form.setAttribute("data-composer-responding-value", "false")
|
|
22
23
|
})
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
// A tool call is waiting to be approved: the composer stays disabled, and
|
|
27
|
+
// holds there for as long as it takes rather than timing out.
|
|
28
|
+
Turbo.StreamActions.wait_composer = function () {
|
|
29
|
+
this.targetElements.forEach((form) => {
|
|
30
|
+
form.setAttribute("data-composer-waiting-value", "true")
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
function syncStreamingContent(target, markdown) {
|
|
26
35
|
const tmp = document.createElement("div")
|
|
27
36
|
tmp.innerHTML = renderMarkdown(markdown)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Layered
|
|
2
|
+
module Assistant
|
|
3
|
+
module Messages
|
|
4
|
+
# Carries out a tool call once it has been approved, or writes down the
|
|
5
|
+
# refusal, then picks the response back up. Runs out of band because a
|
|
6
|
+
# tool can be slow and the answer streams back like any other.
|
|
7
|
+
class ToolCallJob < ApplicationJob
|
|
8
|
+
queue_as :default
|
|
9
|
+
|
|
10
|
+
def perform(message_id)
|
|
11
|
+
message = Message.find(message_id)
|
|
12
|
+
# Nothing to do for a call still waiting on a decision. A call that
|
|
13
|
+
# is already answered is not run again, but is still passed on: a
|
|
14
|
+
# retry may be here because resuming the response is what failed.
|
|
15
|
+
return if message.consent_pending?
|
|
16
|
+
|
|
17
|
+
ToolRunnerService.new.resolve(message: message)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -42,12 +42,50 @@ module Layered
|
|
|
42
42
|
"New conversation"
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# The composer stays disabled while either is true: the assistant is
|
|
46
|
+
# still writing, or a tool call has yet to reach an answer.
|
|
45
47
|
def responding?
|
|
48
|
+
generating? || unresolved_tool_call?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def generating?
|
|
46
52
|
messages.where(role: :assistant, stopped: false, output_tokens: nil).exists?
|
|
47
53
|
end
|
|
48
54
|
|
|
55
|
+
# Whether the response was stopped where it stands. The latest assistant
|
|
56
|
+
# message carries the mark, so a fresh turn clears it.
|
|
57
|
+
def stopped?
|
|
58
|
+
last_assistant_message&.stopped? || false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def awaiting_consent?
|
|
62
|
+
pending_tool_calls.exists?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def pending_tool_calls
|
|
66
|
+
messages.where(role: :tool, tool_status: :pending)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A call that was put to the person talking and has yet to reach an
|
|
70
|
+
# answer. The decision is recorded before the result is written, so a
|
|
71
|
+
# call that has been answered is still unresolved until the job says
|
|
72
|
+
# what came of it - a refusal included. Nothing may be sent to the model
|
|
73
|
+
# until every one of them holds a result.
|
|
74
|
+
def unresolved_tool_calls
|
|
75
|
+
messages.where(role: :tool, content: nil).where.not(tool_status: nil)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def unresolved_tool_call?
|
|
79
|
+
unresolved_tool_calls.exists?
|
|
80
|
+
end
|
|
81
|
+
|
|
49
82
|
def stop_response!
|
|
50
83
|
with_lock do
|
|
84
|
+
# Stopping while a tool call is unanswered is an answer: the calls
|
|
85
|
+
# are abandoned and the response is not picked back up, which would
|
|
86
|
+
# only ask the model to try again.
|
|
87
|
+
return abandon_unresolved_tool_calls! if unresolved_tool_call?
|
|
88
|
+
|
|
51
89
|
message = messages.where(role: :assistant, stopped: false).order(created_at: :desc).first
|
|
52
90
|
return false unless message
|
|
53
91
|
|
|
@@ -83,6 +121,31 @@ module Layered
|
|
|
83
121
|
|
|
84
122
|
private
|
|
85
123
|
|
|
124
|
+
def abandon_unresolved_tool_calls!
|
|
125
|
+
last = nil
|
|
126
|
+
|
|
127
|
+
unresolved_tool_calls.each do |message|
|
|
128
|
+
next unless ToolRunnerService.abandon(message)
|
|
129
|
+
|
|
130
|
+
message.broadcast_updated
|
|
131
|
+
last = message
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The message that asked for the tools is already complete, with its
|
|
135
|
+
# real token counts, so it is marked stopped without being estimated
|
|
136
|
+
# over. That mark is what keeps an approved call still running, or a
|
|
137
|
+
# job retried later, from picking the response back up.
|
|
138
|
+
last_assistant_message&.update!(stopped: true)
|
|
139
|
+
|
|
140
|
+
update_token_totals!
|
|
141
|
+
last&.broadcast_response_complete
|
|
142
|
+
true
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def last_assistant_message
|
|
146
|
+
messages.where(role: :assistant).order(created_at: :desc, id: :desc).first
|
|
147
|
+
end
|
|
148
|
+
|
|
86
149
|
def create_system_message
|
|
87
150
|
prompt = SystemPromptService.new.call(assistant: assistant)
|
|
88
151
|
return if prompt.blank?
|
|
@@ -15,8 +15,21 @@ module Layered
|
|
|
15
15
|
tool: "tool"
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
# Where a tool call that asked for consent has got to. Null for a call
|
|
19
|
+
# that ran unattended, which is most of them.
|
|
20
|
+
enum :tool_status, {
|
|
21
|
+
pending: "pending",
|
|
22
|
+
approved: "approved",
|
|
23
|
+
running: "running",
|
|
24
|
+
declined: "declined"
|
|
25
|
+
}, prefix: :consent
|
|
26
|
+
|
|
18
27
|
# Validations
|
|
19
|
-
|
|
28
|
+
# A tool call under consent is written in two steps - the decision is
|
|
29
|
+
# recorded, then the call runs and its answer is written - so it holds
|
|
30
|
+
# no content in between. Every other tool message has its answer from
|
|
31
|
+
# the moment it exists.
|
|
32
|
+
validates :content, presence: true, unless: -> { assistant? || under_consent? }
|
|
20
33
|
|
|
21
34
|
# Associations
|
|
22
35
|
belongs_to :conversation, counter_cache: true
|
|
@@ -31,6 +44,51 @@ module Layered
|
|
|
31
44
|
# Scopes
|
|
32
45
|
scope :by_created_at, -> { order(created_at: :asc, id: :asc) }
|
|
33
46
|
|
|
47
|
+
# Whether this message is a tool call that was put to the person
|
|
48
|
+
# talking, whatever they said to it.
|
|
49
|
+
def under_consent?
|
|
50
|
+
tool_status.present?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Writes the outcome of a call that was waiting to be approved. Until
|
|
54
|
+
# this runs the message is the question; afterwards it is the answer,
|
|
55
|
+
# and reads like any other tool message.
|
|
56
|
+
#
|
|
57
|
+
# Conditional on the call still being unanswered, because stopping the
|
|
58
|
+
# response answers a waiting call on its behalf: whichever gets there
|
|
59
|
+
# first wins, and the loser is told so rather than overwriting it.
|
|
60
|
+
# `from` narrows that to particular states, so a caller can decline to
|
|
61
|
+
# overtake a call whose tool is already running.
|
|
62
|
+
def resolve_tool_call!(status:, content:, from: nil)
|
|
63
|
+
scope = self.class.where(id: id, content: nil)
|
|
64
|
+
scope = scope.where(tool_status: from) if from
|
|
65
|
+
|
|
66
|
+
written = scope.update_all(
|
|
67
|
+
tool_status: status,
|
|
68
|
+
content: content,
|
|
69
|
+
input_tokens: TokenEstimator.estimate(content),
|
|
70
|
+
tokens_estimated: true,
|
|
71
|
+
updated_at: Time.current
|
|
72
|
+
)
|
|
73
|
+
return false if written.zero?
|
|
74
|
+
|
|
75
|
+
reload
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Claims an approved call, so that the tool runs once and only once. The
|
|
80
|
+
# claim is the same row stopping the response competes for: a tool under
|
|
81
|
+
# consent writes, spends or sends, so it must not run after a Stop, and
|
|
82
|
+
# must not run twice because a job was delivered twice.
|
|
83
|
+
def claim_tool_call!
|
|
84
|
+
claimed = self.class.where(id: id, tool_status: "approved", content: nil)
|
|
85
|
+
.update_all(tool_status: "running", updated_at: Time.current)
|
|
86
|
+
return false if claimed.zero?
|
|
87
|
+
|
|
88
|
+
reload
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
|
|
34
92
|
# Broadcasting
|
|
35
93
|
def broadcast_created
|
|
36
94
|
broadcast_append_to conversation,
|
|
@@ -46,6 +104,15 @@ module Layered
|
|
|
46
104
|
locals: { message: self }
|
|
47
105
|
end
|
|
48
106
|
|
|
107
|
+
# Tells the composer the response is not lost, only waiting: it holds
|
|
108
|
+
# its ground rather than giving up on a response that is doing exactly
|
|
109
|
+
# what it should - nothing, until the call is answered.
|
|
110
|
+
def broadcast_response_waiting
|
|
111
|
+
broadcast_action_to conversation,
|
|
112
|
+
action: :wait_composer,
|
|
113
|
+
targets: ".#{dom_id(conversation)}_composer"
|
|
114
|
+
end
|
|
115
|
+
|
|
49
116
|
def broadcast_response_complete
|
|
50
117
|
broadcast_action_to conversation,
|
|
51
118
|
action: :enable_composer,
|
|
@@ -4,7 +4,29 @@ module Layered
|
|
|
4
4
|
# result, then queues a fresh assistant message so the model can answer
|
|
5
5
|
# with what the tools returned. That message may ask for tools again,
|
|
6
6
|
# which is the loop max_tool_cycles bounds.
|
|
7
|
+
#
|
|
8
|
+
# A tool that declares `consent :always` is not run here. Its message is
|
|
9
|
+
# recorded with nothing in it, the response stops where it is, and the
|
|
10
|
+
# person talking approves or declines it - at which point #resolve
|
|
11
|
+
# finishes the call and picks the response back up.
|
|
7
12
|
class ToolRunnerService
|
|
13
|
+
DECLINED = "The person you are talking to declined this tool call.".freeze
|
|
14
|
+
STOPPED = "The response was stopped before this tool ran.".freeze
|
|
15
|
+
|
|
16
|
+
# Results are reported to the model as JSON, a refusal included, so
|
|
17
|
+
# that being turned down reads like any other unhappy answer.
|
|
18
|
+
def self.error(reason)
|
|
19
|
+
{ error: reason }.to_json
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Writes down a call that was never answered because the response was
|
|
23
|
+
# stopped. Nothing is resumed: stopping means stopping. Returns false if
|
|
24
|
+
# the call is beyond stopping - already answered, or claimed by the job
|
|
25
|
+
# that is running its tool, whose own result is the true one.
|
|
26
|
+
def self.abandon(message)
|
|
27
|
+
message.resolve_tool_call!(status: :declined, content: error(STOPPED), from: %w[pending approved])
|
|
28
|
+
end
|
|
29
|
+
|
|
8
30
|
def call(message:)
|
|
9
31
|
return if message.tool_calls.blank?
|
|
10
32
|
|
|
@@ -16,11 +38,43 @@ module Layered
|
|
|
16
38
|
message.tool_calls.each { |tool_call| record_result(message, tool_call, available) }
|
|
17
39
|
conversation.update_token_totals!
|
|
18
40
|
|
|
19
|
-
if
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
message.broadcast_response_waiting if conversation.awaiting_consent?
|
|
42
|
+
|
|
43
|
+
resume(message)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Carries out a call once it has been approved, or writes down the
|
|
47
|
+
# refusal, and resumes the response. The refusal is reported to the
|
|
48
|
+
# model as the tool's result rather than ending the conversation: it
|
|
49
|
+
# can say something useful about being turned down.
|
|
50
|
+
def resolve(message:)
|
|
51
|
+
# An answered call is one this job has already run, or one the response
|
|
52
|
+
# was stopped over. The tool does not run twice - but resuming may be
|
|
53
|
+
# what failed last time, so that is still attempted below.
|
|
54
|
+
if message.content.blank?
|
|
55
|
+
# Held from before the claim, because claiming moves the call to
|
|
56
|
+
# running. Running is the tool being carried out, not an outcome, so
|
|
57
|
+
# the answer is written back under the decision that allowed it.
|
|
58
|
+
decided = message.tool_status
|
|
59
|
+
|
|
60
|
+
content = if message.consent_declined?
|
|
61
|
+
error(DECLINED)
|
|
62
|
+
elsif message.claim_tool_call!
|
|
63
|
+
execute(message, message.tool_name, message.tool_arguments, ToolRegistry.for(message.conversation))
|
|
64
|
+
else
|
|
65
|
+
# The claim went elsewhere: the response was stopped before the
|
|
66
|
+
# tool ran, or this job was delivered twice. Either way the tool
|
|
67
|
+
# does not run here, and whoever holds the claim finishes the job.
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return unless message.resolve_tool_call!(status: decided, content: content)
|
|
72
|
+
|
|
73
|
+
message.broadcast_updated
|
|
74
|
+
message.conversation.update_token_totals!
|
|
23
75
|
end
|
|
76
|
+
|
|
77
|
+
resume(message)
|
|
24
78
|
end
|
|
25
79
|
|
|
26
80
|
private
|
|
@@ -28,7 +82,7 @@ module Layered
|
|
|
28
82
|
def record_result(message, tool_call, available)
|
|
29
83
|
name = tool_call.dig("function", "name")
|
|
30
84
|
arguments = tool_call.dig("function", "arguments")
|
|
31
|
-
|
|
85
|
+
tool = available.find { |candidate| candidate.tool_name == name }
|
|
32
86
|
|
|
33
87
|
# Both protocols name the call they are asking for. Without an id the
|
|
34
88
|
# result cannot be paired back to it, and the provider rejects the next
|
|
@@ -37,6 +91,14 @@ module Layered
|
|
|
37
91
|
Rails.logger.error("Tool call for '#{name}' arrived with no id on message #{message.id}")
|
|
38
92
|
end
|
|
39
93
|
|
|
94
|
+
if tool&.consent_required?
|
|
95
|
+
record(message, tool_call, name, arguments, content: nil, status: :pending)
|
|
96
|
+
else
|
|
97
|
+
record(message, tool_call, name, arguments, content: execute(message, name, arguments, available))
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def record(message, tool_call, name, arguments, content:, status: nil)
|
|
40
102
|
result = message.conversation.messages.create!(
|
|
41
103
|
role: :tool,
|
|
42
104
|
content: content,
|
|
@@ -44,6 +106,7 @@ module Layered
|
|
|
44
106
|
tool_call_id: tool_call["id"],
|
|
45
107
|
tool_name: name,
|
|
46
108
|
tool_arguments: arguments,
|
|
109
|
+
tool_status: status,
|
|
47
110
|
input_tokens: TokenEstimator.estimate(content),
|
|
48
111
|
tokens_estimated: true
|
|
49
112
|
)
|
|
@@ -92,7 +155,7 @@ module Layered
|
|
|
92
155
|
end
|
|
93
156
|
|
|
94
157
|
def error(reason)
|
|
95
|
-
|
|
158
|
+
self.class.error(reason)
|
|
96
159
|
end
|
|
97
160
|
|
|
98
161
|
# One cycle is an assistant message that asked for tools. Counted from
|
|
@@ -105,6 +168,72 @@ module Layered
|
|
|
105
168
|
scope.where.not(tool_calls: nil).count
|
|
106
169
|
end
|
|
107
170
|
|
|
171
|
+
# Picks the response back up once every call in the batch has an answer.
|
|
172
|
+
# Taken under a lock, and refusing to queue a second follow-up, because
|
|
173
|
+
# two calls approved at once would otherwise both find themselves last.
|
|
174
|
+
def resume(message)
|
|
175
|
+
conversation = message.conversation
|
|
176
|
+
|
|
177
|
+
conversation.with_lock do
|
|
178
|
+
if conversation.stopped?
|
|
179
|
+
# A tool claimed before the Stop still finishes, and the response
|
|
180
|
+
# is not picked back up. But a tab that loaded while it was
|
|
181
|
+
# running is waiting on it, so say the response is over once
|
|
182
|
+
# nothing is left running - otherwise its composer never comes
|
|
183
|
+
# back.
|
|
184
|
+
message.broadcast_response_complete unless conversation.unresolved_tool_call?
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Every call in the batch has to hold a result before the model is
|
|
189
|
+
# shown any of them: approving two at once means the first to finish
|
|
190
|
+
# would otherwise send a turn with the second still empty.
|
|
191
|
+
return if conversation.unresolved_tool_call?
|
|
192
|
+
return if awaiting_results?(conversation)
|
|
193
|
+
return if followed_up?(conversation, message)
|
|
194
|
+
|
|
195
|
+
if cycles_since_last_prompt(conversation) >= Layered::Assistant.max_tool_cycles
|
|
196
|
+
halt(message)
|
|
197
|
+
else
|
|
198
|
+
continue(message)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Results are written down one call at a time, so a batch is briefly
|
|
204
|
+
# part-recorded. A call put to the person talking is answerable the
|
|
205
|
+
# moment its own row exists, and approving it while a slower call in the
|
|
206
|
+
# same batch is still running would otherwise find nothing unresolved -
|
|
207
|
+
# there being no row yet to be unresolved - and send a turn missing that
|
|
208
|
+
# result. So the batch is measured against what the model asked for
|
|
209
|
+
# rather than against what has been written down so far.
|
|
210
|
+
def awaiting_results?(conversation)
|
|
211
|
+
asked = conversation.messages
|
|
212
|
+
.where(role: :assistant).where.not(tool_calls: nil)
|
|
213
|
+
.order(created_at: :desc, id: :desc).first
|
|
214
|
+
return false unless asked
|
|
215
|
+
|
|
216
|
+
ids = asked.tool_calls.filter_map { |tool_call| tool_call["id"].presence }
|
|
217
|
+
return false if ids.empty?
|
|
218
|
+
|
|
219
|
+
conversation.messages.where(role: :tool, tool_call_id: ids).count < ids.size
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Whether the conversation has already moved past this batch, which is
|
|
223
|
+
# what makes resuming safe to attempt twice. Any assistant message from
|
|
224
|
+
# this point on is that move, finished or not: a follow-up that has since
|
|
225
|
+
# completed still means the batch was resumed, and a job delivered late
|
|
226
|
+
# must not queue a second response for it. The message passed in is
|
|
227
|
+
# excluded because on the unattended path it is itself an assistant
|
|
228
|
+
# message - the one that asked for the tools.
|
|
229
|
+
def followed_up?(conversation, message)
|
|
230
|
+
conversation.messages
|
|
231
|
+
.where(role: :assistant)
|
|
232
|
+
.where(created_at: message.created_at..)
|
|
233
|
+
.where.not(id: message.id)
|
|
234
|
+
.exists?
|
|
235
|
+
end
|
|
236
|
+
|
|
108
237
|
def continue(message)
|
|
109
238
|
follow_up = message.conversation.messages.create!(
|
|
110
239
|
role: :assistant,
|
|
@@ -58,6 +58,50 @@ module Layered
|
|
|
58
58
|
from_superclass(:public?) || false
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# Who may call the tool, as a block returning truthy to allow it:
|
|
62
|
+
#
|
|
63
|
+
# permit { |conversation| conversation.user&.admin? }
|
|
64
|
+
#
|
|
65
|
+
# This narrows what an assistant has been given rather than replacing
|
|
66
|
+
# it: a tool still has to be selected on the assistant before anyone
|
|
67
|
+
# can call it. An unpermitted tool is left out of the definitions sent
|
|
68
|
+
# to the provider, and refused if the model asks for it anyway from an
|
|
69
|
+
# earlier turn's history.
|
|
70
|
+
#
|
|
71
|
+
# Inherited like the other declarations, so a tool subclassed to share
|
|
72
|
+
# logic keeps its parent's policy unless it declares its own.
|
|
73
|
+
def permit(&block)
|
|
74
|
+
@permit = block if block
|
|
75
|
+
@permit || from_superclass(:permit)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Whether a call has to be approved by the person talking before it
|
|
79
|
+
# runs. Reads are usually fine unattended; anything that writes, spends
|
|
80
|
+
# or sends is worth asking about first.
|
|
81
|
+
#
|
|
82
|
+
# consent :always
|
|
83
|
+
#
|
|
84
|
+
# A tool that asks for consent is withheld from a conversation with no
|
|
85
|
+
# user or no owner: an anonymous visitor gives nobody to ask, and a
|
|
86
|
+
# public assistant's conversation has no owner to answer as, so a
|
|
87
|
+
# waiting call there could never be approved. Inherited like the other
|
|
88
|
+
# declarations.
|
|
89
|
+
CONSENT = %i[never always].freeze
|
|
90
|
+
|
|
91
|
+
def consent(value = nil)
|
|
92
|
+
if value
|
|
93
|
+
raise ::ArgumentError, "Unsupported consent: #{value}" unless CONSENT.include?(value.to_sym)
|
|
94
|
+
|
|
95
|
+
@consent = value.to_sym
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@consent || from_superclass(:consent) || :never
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def consent_required?
|
|
102
|
+
consent == :always
|
|
103
|
+
end
|
|
104
|
+
|
|
61
105
|
def argument(name, type = :string, required: false, description: nil, enum: nil, items: nil)
|
|
62
106
|
type = type.to_s
|
|
63
107
|
raise ::ArgumentError, "Unsupported argument type: #{type}" unless TYPES.include?(type)
|
|
@@ -97,8 +141,17 @@ module Layered
|
|
|
97
141
|
}
|
|
98
142
|
end
|
|
99
143
|
|
|
144
|
+
# Whether a conversation may be offered the tool at all. Cheapest
|
|
145
|
+
# gate first: a private tool needs an owner to scope its reads to, a
|
|
146
|
+
# tool that asks for consent needs somebody to ask and an owner to
|
|
147
|
+
# answer as, the host's authorize_tool block may narrow every tool at
|
|
148
|
+
# once, and the tool's own permit block has the last word.
|
|
100
149
|
def available_for?(conversation)
|
|
101
|
-
public? || conversation&.owner.present?
|
|
150
|
+
return false unless public? || conversation&.owner.present?
|
|
151
|
+
return false if consent_required? && !consentable?(conversation)
|
|
152
|
+
return false unless host_permits?(conversation)
|
|
153
|
+
|
|
154
|
+
permits?(conversation)
|
|
102
155
|
end
|
|
103
156
|
|
|
104
157
|
# Checks what the model supplied against the schema and returns it as
|
|
@@ -136,6 +189,40 @@ module Layered
|
|
|
136
189
|
def default_tool_name
|
|
137
190
|
name.underscore.sub(/_tool\z/, "").tr("/", "-")
|
|
138
191
|
end
|
|
192
|
+
|
|
193
|
+
# A waiting call is approved through the owner-scoped route, so a
|
|
194
|
+
# conversation with no owner - a public assistant's - has no way to
|
|
195
|
+
# answer one, whoever is signed in. Both are required: somebody to
|
|
196
|
+
# ask, and an owner to reach the decision with.
|
|
197
|
+
def consentable?(conversation)
|
|
198
|
+
conversation&.user.present? && conversation&.owner.present?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def permits?(conversation)
|
|
202
|
+
block = permit
|
|
203
|
+
return true unless block
|
|
204
|
+
|
|
205
|
+
allowed?("The permit block for '#{tool_name}'") do
|
|
206
|
+
block.arity.zero? ? block.call : block.call(conversation)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def host_permits?(conversation)
|
|
211
|
+
block = Layered::Assistant.authorize_tool_block
|
|
212
|
+
return true unless block
|
|
213
|
+
|
|
214
|
+
allowed?("The authorize_tool block") { block.call(self, conversation) }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# A policy that raises denies the tool rather than failing the whole
|
|
218
|
+
# response: a broken block should not hand the tool over, and should
|
|
219
|
+
# not take the conversation down with it either.
|
|
220
|
+
def allowed?(subject)
|
|
221
|
+
!!yield
|
|
222
|
+
rescue => e
|
|
223
|
+
Rails.logger.error("#{subject} raised #{e.class}: #{e.message} - denying the tool")
|
|
224
|
+
false
|
|
225
|
+
end
|
|
139
226
|
end
|
|
140
227
|
|
|
141
228
|
attr_reader :message
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
<%= form_with url: layered_assistant.conversation_messages_path(conversation), id: "composer-form", class: "#{dom_id(conversation)}_composer", data: { controller: "composer", composer_target: "form", action: "submit->composer#submit", composer_responding_value: local_assigns.fetch(:responding, false), composer_stop_url_value: layered_assistant.stop_conversation_path(conversation) } do |f| %>
|
|
1
|
+
<%= form_with url: layered_assistant.conversation_messages_path(conversation), id: "composer-form", class: "#{dom_id(conversation)}_composer", data: { controller: "composer", composer_target: "form", action: "submit->composer#submit", composer_responding_value: local_assigns.fetch(:responding, false), composer_waiting_value: conversation.unresolved_tool_call?, composer_stop_url_value: layered_assistant.stop_conversation_path(conversation) } do |f| %>
|
|
2
2
|
<%= render "layered/assistant/messages/composer_fields", f: f, models: models, selected_model_id: selected_model_id %>
|
|
3
3
|
<% end %>
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
<% pending = message.consent_pending? %>
|
|
2
|
+
<%# An approved call holds nothing until the tool has run, which for a slow %>
|
|
3
|
+
<%# one is a while. Saying so beats an empty Output block. %>
|
|
4
|
+
<% running = message.under_consent? && message.content.blank? %>
|
|
1
5
|
<%= tag.div id: dom_id(message), data: { created_at: (message.created_at.to_f * 1000).to_i }, class: "#{dom_id(message)} l-ui-message" do %>
|
|
2
6
|
<div class="l-ui-message__bubble">
|
|
3
|
-
|
|
7
|
+
<%# A call waiting to be approved is opened: nobody should be asked to %>
|
|
8
|
+
<%# approve arguments they have to go looking for. %>
|
|
9
|
+
<%= tag.details class: "l-ui-surface l-ui-surface--collapsible-highlighted l-ui-surface--sm", open: pending do %>
|
|
4
10
|
<summary class="l-ui-surface__summary">
|
|
5
11
|
<span><strong>Tool:</strong> <%= message.tool_name %></span>
|
|
6
12
|
<%= image_tag "layered_ui/icon_chevron_right.svg", alt: "", class: "l-ui-icon l-ui-icon--sm l-ui-surface__chevron", aria: { hidden: true } %>
|
|
@@ -8,10 +14,39 @@
|
|
|
8
14
|
<div class="l-ui-surface__content l-ui-markdown">
|
|
9
15
|
<p>Input:</p>
|
|
10
16
|
<pre><code><%= tool_payload(message.tool_arguments) %></code></pre>
|
|
11
|
-
|
|
12
|
-
|
|
17
|
+
<% if message.content.present? %>
|
|
18
|
+
<p>Output:</p>
|
|
19
|
+
<pre><code><%= tool_payload(message.content) %></code></pre>
|
|
20
|
+
<% end %>
|
|
13
21
|
</div>
|
|
14
|
-
|
|
22
|
+
<% end %>
|
|
23
|
+
<% if pending %>
|
|
24
|
+
<%# The notice and the decision are one thing, so they sit in one child %>
|
|
25
|
+
<%# of the bubble: its gap would otherwise stack with the notice's own %>
|
|
26
|
+
<%# bottom margin. A plain row, not l-ui-form__actions - that is a page %>
|
|
27
|
+
<%# form's footer, right-aligned and set well clear of the fields above. %>
|
|
28
|
+
<div>
|
|
29
|
+
<div class="l-ui-notice l-ui-notice--warning" role="status">This tool needs your approval before it runs.</div>
|
|
30
|
+
<div class="l-ui-tag-row">
|
|
31
|
+
<%= button_to "Approve",
|
|
32
|
+
layered_assistant.conversation_tool_call_path(message.conversation, message),
|
|
33
|
+
method: :patch,
|
|
34
|
+
params: { decision: "approve" },
|
|
35
|
+
class: "l-ui-button l-ui-button--primary l-ui-button--small",
|
|
36
|
+
aria: { label: "Approve the call to #{message.tool_name}" } %>
|
|
37
|
+
<%= button_to "Decline",
|
|
38
|
+
layered_assistant.conversation_tool_call_path(message.conversation, message),
|
|
39
|
+
method: :patch,
|
|
40
|
+
params: { decision: "decline" },
|
|
41
|
+
class: "l-ui-button l-ui-button--outline-danger l-ui-button--small",
|
|
42
|
+
aria: { label: "Decline the call to #{message.tool_name}" } %>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
<% elsif running %>
|
|
46
|
+
<div class="l-ui-notice" role="status">This tool call is running.</div>
|
|
47
|
+
<% elsif message.consent_declined? %>
|
|
48
|
+
<div class="l-ui-notice l-ui-notice--warning" role="status">This tool call was declined.</div>
|
|
49
|
+
<% end %>
|
|
15
50
|
<div class="l-ui-message__footer">
|
|
16
51
|
<span class="l-ui-message__timestamp"><%= message.created_at.to_fs(:short) %></span>
|
|
17
52
|
</div>
|
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
<% end %>
|
|
6
6
|
|
|
7
7
|
<%= turbo_stream.replace "composer-form" do %>
|
|
8
|
-
<%= render "layered/assistant/messages/composer", conversation: @conversation, models: @models, selected_model_id: @selected_model_id, responding: @
|
|
8
|
+
<%= render "layered/assistant/messages/composer", conversation: @conversation, models: @models, selected_model_id: @selected_model_id, responding: @responding %>
|
|
9
9
|
<% end %>
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
<%= form_with url: layered_assistant.panel_conversation_messages_path(conversation), id: "panel-composer-form", class: "#{dom_id(conversation)}_composer", data: { controller: "composer", composer_target: "form", turbo_frame: "_top", action: "submit->composer#submit", composer_responding_value: local_assigns.fetch(:responding, false), composer_stop_url_value: layered_assistant.stop_panel_conversation_path(conversation) } do |f| %>
|
|
1
|
+
<%= form_with url: layered_assistant.panel_conversation_messages_path(conversation), id: "panel-composer-form", class: "#{dom_id(conversation)}_composer", data: { controller: "composer", composer_target: "form", turbo_frame: "_top", action: "submit->composer#submit", composer_responding_value: local_assigns.fetch(:responding, false), composer_waiting_value: conversation.unresolved_tool_call?, composer_stop_url_value: layered_assistant.stop_panel_conversation_path(conversation) } do |f| %>
|
|
2
2
|
<%= render "layered/assistant/messages/composer_fields", f: f, models: models, selected_model_id: selected_model_id %>
|
|
3
3
|
<% end %>
|
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
<% end %>
|
|
6
6
|
|
|
7
7
|
<%= turbo_stream.replace "panel-composer-form" do %>
|
|
8
|
-
<%= render "layered/assistant/panel/messages/composer", conversation: @conversation, models: @models, selected_model_id: @selected_model_id, responding: @
|
|
8
|
+
<%= render "layered/assistant/panel/messages/composer", conversation: @conversation, models: @models, selected_model_id: @selected_model_id, responding: @responding %>
|
|
9
9
|
<% end %>
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
<%= form_with url: layered_assistant.public_conversation_messages_path(conversation), id: "public-composer-form", class: "#{dom_id(conversation)}_composer", data: { controller: "composer", composer_target: "form", turbo_frame: "_top", action: "submit->composer#submit", composer_responding_value: local_assigns.fetch(:responding, false), composer_stop_url_value: layered_assistant.stop_public_conversation_path(conversation) } do |f| %>
|
|
1
|
+
<%= form_with url: layered_assistant.public_conversation_messages_path(conversation), id: "public-composer-form", class: "#{dom_id(conversation)}_composer", data: { controller: "composer", composer_target: "form", turbo_frame: "_top", action: "submit->composer#submit", composer_responding_value: local_assigns.fetch(:responding, false), composer_waiting_value: conversation.unresolved_tool_call?, composer_stop_url_value: layered_assistant.stop_public_conversation_path(conversation) } do |f| %>
|
|
2
2
|
<%= render "layered/assistant/messages/composer_fields", f: f %>
|
|
3
3
|
<% end %>
|
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
<% end %>
|
|
6
6
|
|
|
7
7
|
<%= turbo_stream.replace "public-composer-form" do %>
|
|
8
|
-
<%= render "layered/assistant/public/messages/composer", conversation: @conversation, responding: @
|
|
8
|
+
<%= render "layered/assistant/public/messages/composer", conversation: @conversation, responding: @responding %>
|
|
9
9
|
<% end %>
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
<%= form_with url: layered_assistant.public_panel_conversation_messages_path(conversation), id: "public-panel-composer-form", class: "#{dom_id(conversation)}_composer", data: { controller: "composer", composer_target: "form", turbo_frame: "_top", action: "submit->composer#submit", composer_responding_value: local_assigns.fetch(:responding, false), composer_stop_url_value: layered_assistant.stop_public_panel_conversation_path(conversation) } do |f| %>
|
|
1
|
+
<%= form_with url: layered_assistant.public_panel_conversation_messages_path(conversation), id: "public-panel-composer-form", class: "#{dom_id(conversation)}_composer", data: { controller: "composer", composer_target: "form", turbo_frame: "_top", action: "submit->composer#submit", composer_responding_value: local_assigns.fetch(:responding, false), composer_waiting_value: conversation.unresolved_tool_call?, composer_stop_url_value: layered_assistant.stop_public_panel_conversation_path(conversation) } do |f| %>
|
|
2
2
|
<%= render "layered/assistant/messages/composer_fields", f: f %>
|
|
3
3
|
<% end %>
|
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
<% end %>
|
|
6
6
|
|
|
7
7
|
<%= turbo_stream.replace "public-panel-composer-form" do %>
|
|
8
|
-
<%= render "layered/assistant/public/panel/messages/composer", conversation: @conversation, responding: @
|
|
8
|
+
<%= render "layered/assistant/public/panel/messages/composer", conversation: @conversation, responding: @responding %>
|
|
9
9
|
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -13,6 +13,12 @@ Layered::Assistant::Engine.routes.draw do
|
|
|
13
13
|
resources :conversations, only: [ :index, :show, :new, :create, :edit, :update, :destroy ] do
|
|
14
14
|
patch :stop, on: :member
|
|
15
15
|
resources :messages, only: [ :index, :create, :destroy ]
|
|
16
|
+
# Approving a tool call is the same act wherever it is rendered, and the
|
|
17
|
+
# partial carrying the buttons is broadcast rather than requested, so it
|
|
18
|
+
# cannot know which namespace it landed in. One route serves them all.
|
|
19
|
+
# Public conversations are not among them: a tool that asks for consent
|
|
20
|
+
# is withheld from a conversation with nobody to ask.
|
|
21
|
+
resources :tool_calls, only: [ :update ]
|
|
16
22
|
end
|
|
17
23
|
|
|
18
24
|
namespace :panel do
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class AddToolStatusToLayeredAssistantMessages < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# Set on a tool message whose tool asked to be approved before it ran.
|
|
4
|
+
# Null for a call that needed no consent, which is every call made before
|
|
5
|
+
# this column existed.
|
|
6
|
+
add_column :layered_assistant_messages, :tool_status, :string
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -88,6 +88,34 @@
|
|
|
88
88
|
# Each assistant is given its own set on its edit screen, and an assistant
|
|
89
89
|
# with none calls nothing - so adding a tool to the application does not hand
|
|
90
90
|
# it to every assistant at once.
|
|
91
|
+
#
|
|
92
|
+
# Who may call a tool is separate from which assistants have it. A `permit`
|
|
93
|
+
# block narrows a tool to the conversations that satisfy it:
|
|
94
|
+
#
|
|
95
|
+
# class RefundTool < Layered::Assistant::Tool
|
|
96
|
+
# permit { |conversation| conversation.user&.admin? }
|
|
97
|
+
# end
|
|
98
|
+
#
|
|
99
|
+
# An unpermitted tool is left out of the definitions sent to the provider, so
|
|
100
|
+
# the model is never told about it, and is refused if it asks from an earlier
|
|
101
|
+
# turn's history anyway. To apply one rule across every tool, configure an
|
|
102
|
+
# authorize_tool block - it is given the tool class and the conversation, and
|
|
103
|
+
# returning falsey withholds the tool:
|
|
104
|
+
#
|
|
105
|
+
# Layered::Assistant.authorize_tool do |tool, conversation|
|
|
106
|
+
# conversation.user&.permitted_tools&.include?(tool.tool_name)
|
|
107
|
+
# end
|
|
108
|
+
#
|
|
109
|
+
# Separately from who may call a tool, a tool that writes, spends or sends
|
|
110
|
+
# can put each call to the person talking before it runs:
|
|
111
|
+
#
|
|
112
|
+
# class RefundTool < Layered::Assistant::Tool
|
|
113
|
+
# consent :always
|
|
114
|
+
# end
|
|
115
|
+
#
|
|
116
|
+
# The call waits in the conversation with its arguments shown, the composer
|
|
117
|
+
# stays disabled, and the response picks up once it has been approved or
|
|
118
|
+
# declined.
|
|
91
119
|
|
|
92
120
|
# Optional settings (uncomment to enable):
|
|
93
121
|
# Layered::Assistant.log_errors = true # log API errors to stdout
|
data/lib/layered/assistant.rb
CHANGED
|
@@ -15,6 +15,7 @@ module Layered
|
|
|
15
15
|
mattr_reader :authorize_block
|
|
16
16
|
mattr_reader :owner_block
|
|
17
17
|
mattr_reader :tools_block
|
|
18
|
+
mattr_reader :authorize_tool_block
|
|
18
19
|
mattr_accessor :log_errors, default: false
|
|
19
20
|
mattr_accessor :api_request_timeout, default: 210
|
|
20
21
|
mattr_accessor :skip_db_encryption, default: false
|
|
@@ -33,5 +34,16 @@ module Layered
|
|
|
33
34
|
def self.tools(&block)
|
|
34
35
|
@@tools_block = block
|
|
35
36
|
end
|
|
37
|
+
|
|
38
|
+
# A veto over every tool call, given the tool class and the conversation
|
|
39
|
+
# it was asked for in. Returning falsey withholds the tool, as a tool's
|
|
40
|
+
# own `permit` block does for itself.
|
|
41
|
+
#
|
|
42
|
+
# Unlike `authorize`, which guards the engine's routes, this is left open
|
|
43
|
+
# when unconfigured: tool calls are already behind that block and behind
|
|
44
|
+
# the set of tools each assistant has been given.
|
|
45
|
+
def self.authorize_tool(&block)
|
|
46
|
+
@@authorize_tool_block = block
|
|
47
|
+
end
|
|
36
48
|
end
|
|
37
49
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: layered-assistant-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- layered.ai
|
|
@@ -327,6 +327,7 @@ files:
|
|
|
327
327
|
- app/controllers/layered/assistant/public/panel/messages_controller.rb
|
|
328
328
|
- app/controllers/layered/assistant/resources_controller.rb
|
|
329
329
|
- app/controllers/layered/assistant/setup_controller.rb
|
|
330
|
+
- app/controllers/layered/assistant/tool_calls_controller.rb
|
|
330
331
|
- app/helpers/layered/assistant/access_helper.rb
|
|
331
332
|
- app/helpers/layered/assistant/messages_helper.rb
|
|
332
333
|
- app/helpers/layered/assistant/panel_helper.rb
|
|
@@ -346,6 +347,7 @@ files:
|
|
|
346
347
|
- app/javascript/layered_assistant/vendor/marked.js
|
|
347
348
|
- app/jobs/layered/assistant/application_job.rb
|
|
348
349
|
- app/jobs/layered/assistant/messages/response_job.rb
|
|
350
|
+
- app/jobs/layered/assistant/messages/tool_call_job.rb
|
|
349
351
|
- app/layered_resources/layered/assistant/assistant_resource.rb
|
|
350
352
|
- app/layered_resources/layered/assistant/model_resource.rb
|
|
351
353
|
- app/layered_resources/layered/assistant/persona_resource.rb
|
|
@@ -427,6 +429,7 @@ files:
|
|
|
427
429
|
- db/migrate/20260831000000_add_tool_calling_to_layered_assistant_messages.rb
|
|
428
430
|
- db/migrate/20260901000000_create_layered_assistant_assistant_tools.rb
|
|
429
431
|
- db/migrate/20260901000001_add_user_to_layered_assistant_conversations.rb
|
|
432
|
+
- db/migrate/20260919000000_add_tool_status_to_layered_assistant_messages.rb
|
|
430
433
|
- lib/generators/layered/assistant/install_agent_skill_generator.rb
|
|
431
434
|
- lib/generators/layered/assistant/install_generator.rb
|
|
432
435
|
- lib/generators/layered/assistant/migrations_generator.rb
|