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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +79 -3
  3. data/app/controllers/concerns/layered/assistant/message_creation.rb +17 -1
  4. data/app/controllers/layered/assistant/messages_controller.rb +3 -2
  5. data/app/controllers/layered/assistant/panel/messages_controller.rb +3 -2
  6. data/app/controllers/layered/assistant/public/messages_controller.rb +3 -3
  7. data/app/controllers/layered/assistant/public/panel/messages_controller.rb +3 -3
  8. data/app/controllers/layered/assistant/tool_calls_controller.rb +34 -0
  9. data/app/javascript/layered_assistant/composer_controller.js +19 -4
  10. data/app/javascript/layered_assistant/message_streaming.js +9 -0
  11. data/app/jobs/layered/assistant/messages/tool_call_job.rb +22 -0
  12. data/app/models/layered/assistant/conversation.rb +63 -0
  13. data/app/models/layered/assistant/message.rb +68 -1
  14. data/app/services/layered/assistant/tool_runner_service.rb +135 -6
  15. data/app/tools/layered/assistant/tool.rb +88 -1
  16. data/app/views/layered/assistant/messages/_composer.html.erb +1 -1
  17. data/app/views/layered/assistant/messages/_tool_message.html.erb +39 -4
  18. data/app/views/layered/assistant/messages/create.turbo_stream.erb +1 -1
  19. data/app/views/layered/assistant/panel/messages/_composer.html.erb +1 -1
  20. data/app/views/layered/assistant/panel/messages/create.turbo_stream.erb +1 -1
  21. data/app/views/layered/assistant/public/messages/_composer.html.erb +1 -1
  22. data/app/views/layered/assistant/public/messages/create.turbo_stream.erb +1 -1
  23. data/app/views/layered/assistant/public/panel/messages/_composer.html.erb +1 -1
  24. data/app/views/layered/assistant/public/panel/messages/create.turbo_stream.erb +1 -1
  25. data/config/routes.rb +6 -0
  26. data/db/migrate/20260919000000_add_tool_status_to_layered_assistant_messages.rb +8 -0
  27. data/lib/generators/layered/assistant/templates/initializer.rb +28 -0
  28. data/lib/layered/assistant/version.rb +1 -1
  29. data/lib/layered/assistant.rb +12 -0
  30. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0849058555cc1dd6e7a2dffb73b3919086413374156a1c3300999eaa543c8a4b'
4
- data.tar.gz: fa46c8ac4e95587216f467be30eb25d8c01c427d642b589b63a0ab849984e4fb
3
+ metadata.gz: 3c4266f3d5e33f449619fe77a6e9b0819bcc22c038ffe3498c96ea56549faa80
4
+ data.tar.gz: 6b4e8a671a99e0e0cfd987b631dc316616743f5b3dbe3b2d3370d8c0a7f1a614
5
5
  SHA512:
6
- metadata.gz: 6b4ae1f8d54d5a753b9560fd532cb04e80cbe1959a728deaa9dfaaa82f83d5a7a65edef6a341a61879f73d1c22d76785692ef430242c7e8f995abc19754bac83
7
- data.tar.gz: d43a15e7ed0b30c8a85dc578a4887b46b537d37e2d881f83fc2135539e60a8b4751dc3bc06fba37ed75e3ee0281b2fa52e089594abf91d31017a94156b065c0a
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 and arguments, adds any
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 = () => this._resetRespondingTimeout()
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
- // 60-second safety timeout resets the composer in case the server
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
- validates :content, presence: true, unless: :assistant?
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 cycles_since_last_prompt(conversation) >= Layered::Assistant.max_tool_cycles
20
- halt(message)
21
- else
22
- continue(message)
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
- content = execute(message, name, arguments, available)
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
- { error: reason }.to_json
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
- <details class="l-ui-surface l-ui-surface--collapsible-highlighted l-ui-surface--sm">
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
- <p>Output:</p>
12
- <pre><code><%= tool_payload(message.content) %></code></pre>
17
+ <% if message.content.present? %>
18
+ <p>Output:</p>
19
+ <pre><code><%= tool_payload(message.content) %></code></pre>
20
+ <% end %>
13
21
  </div>
14
- </details>
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: @error.nil? %>
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: @error.nil? %>
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: @error.nil? %>
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: @error.nil? %>
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
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Assistant
3
- VERSION = "0.7.0"
3
+ VERSION = "0.8.0"
4
4
  end
5
5
  end
@@ -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.7.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