silas 0.3.2 → 0.5.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/CHANGELOG.md +193 -0
- data/README.md +12 -6
- data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
- data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
- data/app/jobs/silas/agent_loop_job.rb +2 -0
- data/app/jobs/silas/channel_delivery_job.rb +15 -0
- data/app/jobs/silas/dead_job_rescuer_job.rb +10 -2
- data/app/models/silas/compaction.rb +32 -0
- data/app/models/silas/tool_invocation.rb +37 -3
- data/app/models/silas/turn.rb +7 -1
- data/app/views/silas/channel_mailer/approval.text.erb +2 -2
- data/app/views/silas/channels/approvals/show.html.erb +1 -1
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +28 -14
- data/config/brakeman.ignore +11 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
- data/lib/generators/silas/channel/channel_generator.rb +72 -0
- data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
- data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
- data/lib/generators/silas/install/install_generator.rb +2 -1
- data/lib/generators/silas/install/templates/initializer.rb +1 -1
- data/lib/silas/{engines → adapters}/base.rb +12 -2
- data/lib/silas/adapters/ruby_llm.rb +221 -0
- data/lib/silas/channel.rb +35 -0
- data/lib/silas/chat.rb +2 -2
- data/lib/silas/compactor.rb +178 -0
- data/lib/silas/configuration.rb +40 -9
- data/lib/silas/delta_buffer.rb +3 -3
- data/lib/silas/deprecator.rb +16 -0
- data/lib/silas/engine.rb +15 -2
- data/lib/silas/eval/driver.rb +1 -1
- data/lib/silas/eval/dsl.rb +1 -1
- data/lib/silas/eval/scripted_engine.rb +3 -3
- data/lib/silas/inbox/delta_broadcaster.rb +1 -1
- data/lib/silas/instrumentation.rb +59 -0
- data/lib/silas/ledger.rb +14 -0
- data/lib/silas/log_subscriber.rb +83 -0
- data/lib/silas/message_builder.rb +19 -0
- data/lib/silas/registry.rb +5 -2
- data/lib/silas/schedule.rb +44 -15
- data/lib/silas/slack.rb +10 -7
- data/lib/silas/step_runner.rb +10 -2
- data/lib/silas/tools/ask_question.rb +26 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas/webhook.rb +47 -0
- data/lib/silas.rb +28 -14
- metadata +15 -3
- data/lib/silas/engines/ruby_llm.rb +0 -165
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module Silas
|
|
4
|
+
module Generators
|
|
5
|
+
# rails g silas:channel whatsapp
|
|
6
|
+
#
|
|
7
|
+
# A channel is two halves that must agree on one name, and hand-rolling
|
|
8
|
+
# them is where the mistakes live: inbound needs signature verification and
|
|
9
|
+
# a stable thread key, outbound needs the approval link to reach an
|
|
10
|
+
# operator. This scaffolds both, wired together, with the security
|
|
11
|
+
# decisions already made.
|
|
12
|
+
class ChannelGenerator < Rails::Generators::NamedBase
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Scaffold a Silas channel: outbound Channel class, inbound webhook controller, and its route."
|
|
16
|
+
|
|
17
|
+
# Channel identity is the filename (app/agent/channels/whatsapp.rb ->
|
|
18
|
+
# Agent::Channels::Whatsapp), and Registry#channels resolves it with
|
|
19
|
+
# `camelize`. A filename that isn't a snake_case identifier produces a
|
|
20
|
+
# constant Zeitwerk can't define, and the channel then fails at boot
|
|
21
|
+
# rather than here — so refuse it here, where the message is useful.
|
|
22
|
+
# (Rails' usual normalisation still applies first: `MsTeams` and
|
|
23
|
+
# `ms_teams` both land on ms_teams.)
|
|
24
|
+
def validate_name
|
|
25
|
+
return if file_name.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
26
|
+
|
|
27
|
+
raise Thor::Error, "#{file_name.inspect} is not a valid channel name — " \
|
|
28
|
+
"it must be lowercase words separated by underscores, " \
|
|
29
|
+
"starting with a letter (e.g. whatsapp, ms_teams)."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Templates are .rb.tt (Rails' own convention): the .tt keeps ERB-bearing
|
|
33
|
+
# files out of the linter and off Zeitwerk's radar.
|
|
34
|
+
def create_channel
|
|
35
|
+
template "channel.rb.tt", "app/agent/channels/#{file_name}.rb"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The webhook lives in the HOST app, not the engine: only the host knows
|
|
39
|
+
# the vendor's signature scheme and payload shape. The engine ships
|
|
40
|
+
# routes for Slack alone because it also ships Slack's verification.
|
|
41
|
+
def create_controller
|
|
42
|
+
template "controller.rb.tt", "app/controllers/agent/channels/#{file_name}_controller.rb"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_route
|
|
46
|
+
route %(post "/agent/channels/#{file_name}", to: "agent/channels/#{file_name}#create")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def show_next_steps
|
|
50
|
+
say <<~MSG, :green
|
|
51
|
+
|
|
52
|
+
Channel "#{file_name}" scaffolded:
|
|
53
|
+
app/agent/channels/#{file_name}.rb (outbound: answers + approvals)
|
|
54
|
+
app/controllers/agent/channels/#{file_name}_controller.rb (inbound: webhook)
|
|
55
|
+
config/routes.rb POST /agent/channels/#{file_name}
|
|
56
|
+
|
|
57
|
+
Next:
|
|
58
|
+
1. Set the signing secret:
|
|
59
|
+
bin/rails credentials:edit -> silas:
|
|
60
|
+
#{file_name}:
|
|
61
|
+
signing_secret: ...
|
|
62
|
+
2. Fill in the three TODOs — the vendor's signature scheme, how a
|
|
63
|
+
message maps to a thread key, and how to post a message back.
|
|
64
|
+
3. Point the vendor's webhook at https://<your-host>/agent/channels/#{file_name}
|
|
65
|
+
4. Restart: app/agent/ registers at boot.
|
|
66
|
+
|
|
67
|
+
Full contract and a worked example: docs/channels.md
|
|
68
|
+
MSG
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# <%= class_name %> channel — OUTBOUND delivery for sessions that started in
|
|
2
|
+
# <%= human_name %>. Inbound lives in
|
|
3
|
+
# app/controllers/agent/channels/<%= file_name %>_controller.rb.
|
|
4
|
+
#
|
|
5
|
+
# Identity is this filename: <%= file_name %>.rb -> Agent::Channels::<%= class_name %>,
|
|
6
|
+
# and the controller starts sessions with channel: "<%= file_name %>", which is how
|
|
7
|
+
# Silas finds this class again at delivery time. Delete this file to disable
|
|
8
|
+
# the channel.
|
|
9
|
+
#
|
|
10
|
+
# Both methods run OFF the durable loop, in ChannelDeliveryJob — so a transport
|
|
11
|
+
# outage retries the delivery without re-running a single tool or touching the
|
|
12
|
+
# ledger. Raising here is safe; it costs a retry, not a duplicate side effect.
|
|
13
|
+
class Agent::Channels::<%= class_name %> < Silas::Channel
|
|
14
|
+
# The agent's final answer for a turn.
|
|
15
|
+
def deliver_answer(session:, text:)
|
|
16
|
+
thread = session.metadata["<%= file_name %>"] || {}
|
|
17
|
+
|
|
18
|
+
# TODO: post `text` back to the conversation. `thread` is whatever the
|
|
19
|
+
# controller stashed in metadata (chat id, phone number, message id).
|
|
20
|
+
raise NotImplementedError,
|
|
21
|
+
"Agent::Channels::<%= class_name %>#deliver_answer: post #{text.inspect} to #{thread.inspect}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A tool has parked for human approval; the turn is holding zero compute
|
|
25
|
+
# until someone answers. `approval_url` mints a signed, expiring one-click
|
|
26
|
+
# link that works in any transport.
|
|
27
|
+
def deliver_approval(session:, invocation:)
|
|
28
|
+
approve = Silas::Channel.approval_url(invocation, :approve)
|
|
29
|
+
decline = Silas::Channel.approval_url(invocation, :decline)
|
|
30
|
+
|
|
31
|
+
# SECURITY: an approval must reach an OPERATOR, never whoever started the
|
|
32
|
+
# session — mailing the approve link to the customer who asked for the
|
|
33
|
+
# refund lets them approve it themselves. Deliver to your ops destination
|
|
34
|
+
# (a staff channel, an on-call number), and FAIL CLOSED when it isn't
|
|
35
|
+
# configured: no destination means no approval, not a silent one.
|
|
36
|
+
operator = Rails.application.credentials.dig(:silas, :<%= file_name %>, :operator)
|
|
37
|
+
if operator.blank?
|
|
38
|
+
Rails.logger.warn("[Silas] no credentials.silas.<%= file_name %>.operator — approval for " \
|
|
39
|
+
"invocation #{invocation.id} not delivered (won't send it to the requester).")
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# TODO: send `approve` / `decline` to `operator`, with enough context to
|
|
44
|
+
# decide: invocation.tool_name and invocation.arguments.
|
|
45
|
+
raise NotImplementedError,
|
|
46
|
+
"Agent::Channels::<%= class_name %>#deliver_approval: send #{approve} / #{decline} to #{operator}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# <%= class_name %> channel — INBOUND webhook. Outbound delivery lives in
|
|
2
|
+
# app/agent/channels/<%= file_name %>.rb.
|
|
3
|
+
#
|
|
4
|
+
# Inbound is pure trigger reuse: verify the request is genuine, derive a stable
|
|
5
|
+
# thread key, and hand it to Channel.dispatch — which starts a session for a new
|
|
6
|
+
# thread and continues the existing one for a reply. Nothing here touches the
|
|
7
|
+
# durable loop.
|
|
8
|
+
class Agent::Channels::<%= class_name %>Controller < ActionController::Base
|
|
9
|
+
# A webhook carries no browser session, so no CSRF token can exist. The
|
|
10
|
+
# signature below IS the authentication — do not remove one without the other.
|
|
11
|
+
skip_forgery_protection
|
|
12
|
+
|
|
13
|
+
before_action :verify_webhook!
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
# TODO: derive a STABLE thread key — the same conversation must produce the
|
|
17
|
+
# same key every time, or every message starts a new session. Use the
|
|
18
|
+
# vendor's conversation/thread id, never a per-message id.
|
|
19
|
+
thread_key = params[:conversation_id].to_s
|
|
20
|
+
text = params[:text].to_s
|
|
21
|
+
|
|
22
|
+
return head(:ok) if thread_key.blank? || text.blank?
|
|
23
|
+
|
|
24
|
+
Agent::Channels::<%= class_name %>.dispatch(
|
|
25
|
+
thread_key: thread_key,
|
|
26
|
+
input: text,
|
|
27
|
+
# Whatever deliver_answer needs to reply. Stored on the session, so keep
|
|
28
|
+
# it small and free of secrets — it is visible in the inbox.
|
|
29
|
+
metadata: { "<%= file_name %>" => { "conversation_id" => thread_key } }
|
|
30
|
+
)
|
|
31
|
+
head :ok
|
|
32
|
+
rescue Silas::TurnInProgressError
|
|
33
|
+
# One active turn per session is an invariant, not a queue: a reply that
|
|
34
|
+
# arrives mid-turn is dropped. Tell the user, or buffer it, if that matters
|
|
35
|
+
# for your transport.
|
|
36
|
+
head :ok
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Rejects anything not genuinely signed. Silas::Webhook.verify_hmac handles
|
|
42
|
+
# the parts that are identical everywhere — constant-time comparison, the
|
|
43
|
+
# replay window, and failing closed when no secret is configured; you supply
|
|
44
|
+
# the vendor's shape.
|
|
45
|
+
#
|
|
46
|
+
# TODO: match your vendor's scheme. The three that vary:
|
|
47
|
+
# payload what they sign. Slack signs "v0:#{timestamp}:#{body}"; GitHub
|
|
48
|
+
# and Shopify sign the raw body. It must be request.raw_post,
|
|
49
|
+
# never re-serialized params — different bytes, different HMAC.
|
|
50
|
+
# prefix what they put before the digest ("v0=", "sha256=", or "").
|
|
51
|
+
# digest :hex (nearly everyone) or :base64 (Shopify, Twilio).
|
|
52
|
+
def verify_webhook!
|
|
53
|
+
ok = Silas::Webhook.verify_hmac(
|
|
54
|
+
secret: Rails.application.credentials.dig(:silas, :<%= file_name %>, :signing_secret),
|
|
55
|
+
signature: request.headers["X-Signature"],
|
|
56
|
+
payload: request.raw_post,
|
|
57
|
+
# Omitting a timestamp disables replay protection: a captured request can
|
|
58
|
+
# then be re-sent forever. Pass the vendor's timestamp header if it sends
|
|
59
|
+
# one, and treat its absence as a reason to check the vendor's docs.
|
|
60
|
+
timestamp: request.headers["X-Signature-Timestamp"],
|
|
61
|
+
prefix: "sha256="
|
|
62
|
+
)
|
|
63
|
+
head(:unauthorized) unless ok
|
|
64
|
+
ok
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -109,7 +109,8 @@ module Silas
|
|
|
109
109
|
6. Schedules: edit app/agent/schedules/*, then `bin/rails silas:schedules`
|
|
110
110
|
7. Channels (optional): set credentials.silas.slack.{signing_secret,bot_token}
|
|
111
111
|
for Slack; route inbound mail to Silas::AgentMailbox for email.
|
|
112
|
-
Delete app/agent/channels/{slack,email}.rb to disable.
|
|
112
|
+
Delete app/agent/channels/{slack,email}.rb to disable. Any other
|
|
113
|
+
transport: `bin/rails g silas:channel whatsapp`.
|
|
113
114
|
8. Inbox + web chat: /silas/inbox, deny-by-default — uncomment
|
|
114
115
|
config.inbox_auth in config/initializers/silas.rb to make it visible.
|
|
115
116
|
9. Restart your server if it was running (app/agent/ registers at boot).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Silas.configure do |config|
|
|
2
2
|
# Inference engine: :ruby_llm (API key, any provider RubyLLM supports), or
|
|
3
3
|
# any object responding to #execute_step. See silas/README.
|
|
4
|
-
config.
|
|
4
|
+
config.adapter = :ruby_llm
|
|
5
5
|
|
|
6
6
|
# Any model your installed ruby_llm's registry resolves (newer models may
|
|
7
7
|
# need `RubyLLM.models.refresh!` first). "claude-sonnet-4-5" is the balanced
|
|
@@ -3,11 +3,11 @@ module Silas
|
|
|
3
3
|
# set — consumers must ignore unknown types. Emitted today by Engines::RubyLLM:
|
|
4
4
|
# :message_start — once per model call (before_message)
|
|
5
5
|
# :text_delta — { text: } chunks as the response streams
|
|
6
|
-
# StepRunner coalesces :text_delta into "silas
|
|
6
|
+
# StepRunner coalesces :text_delta into "delta.silas" notifications (see
|
|
7
7
|
# DeltaBuffer); everything else is available to custom engines/hooks.
|
|
8
8
|
Event = Data.define(:type, :payload)
|
|
9
9
|
|
|
10
|
-
module
|
|
10
|
+
module Adapters
|
|
11
11
|
# The inference seam. An engine executes exactly ONE model call for a step
|
|
12
12
|
# and reports what came back; the framework owns the loop, the ledger owns
|
|
13
13
|
# tool execution.
|
|
@@ -25,4 +25,14 @@ module Silas
|
|
|
25
25
|
|
|
26
26
|
ToolCall = Data.define(:id, :name, :arguments)
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
# Renamed Engines:: -> Adapters:: in 0.4, removed in 2.0. Host apps subclass
|
|
30
|
+
# Adapters::Base for custom inference backends, so the old constant keeps
|
|
31
|
+
# resolving (with a warning) rather than blowing up on upgrade.
|
|
32
|
+
module Engines
|
|
33
|
+
def self.const_missing(name)
|
|
34
|
+
Silas.deprecator.warn("Silas::Engines::#{name} is deprecated; use Silas::Adapters::#{name}")
|
|
35
|
+
Silas::Adapters.const_get(name)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
28
38
|
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Adapters
|
|
3
|
+
# The :ruby_llm adapter: ONE model call per step, streamed, with the tool
|
|
4
|
+
# calls handed back unexecuted.
|
|
5
|
+
#
|
|
6
|
+
# RubyLLM's `Chat#complete` runs the whole agentic loop — model, run tools,
|
|
7
|
+
# feed results back, model again. Silas needs a single move, because the
|
|
8
|
+
# step boundary IS the durability boundary (checkpoint, ledger, park). So
|
|
9
|
+
# Chat is used as the BUILDER it is — it owns model resolution, schema
|
|
10
|
+
# normalisation, system instructions and message construction — and the
|
|
11
|
+
# execution goes one layer down to `Provider#complete`, which is exactly
|
|
12
|
+
# what Chat itself calls for a single turn.
|
|
13
|
+
#
|
|
14
|
+
# Everything here is RubyLLM's public API: Chat's attr_readers (model,
|
|
15
|
+
# messages, tools, schema, tool_prefs), Provider.resolve, and
|
|
16
|
+
# Provider#complete. (Until 0.5 this used tool proxies that threw
|
|
17
|
+
# `RubyLLM::Tool::Halt` to abort the loop from inside — RubyLLM 2.0 removes
|
|
18
|
+
# Halt precisely because the loop became caller-controlled, so this binding
|
|
19
|
+
# is both simpler now and the forward-compatible one.)
|
|
20
|
+
class RubyLLM < Base
|
|
21
|
+
def execute_step(context, &on_event)
|
|
22
|
+
chat = build_chat(context)
|
|
23
|
+
|
|
24
|
+
response = Silas.instrument(:step,
|
|
25
|
+
turn_id: context[:turn]&.id,
|
|
26
|
+
index: context[:index],
|
|
27
|
+
model: context[:model]) do
|
|
28
|
+
if on_event
|
|
29
|
+
# Fires before the HTTP request, matching what RubyLLM's
|
|
30
|
+
# before_message callback did under streaming — but ours, and
|
|
31
|
+
# explicitly ordered rather than incidentally so.
|
|
32
|
+
on_event.call(Event.new(type: :message_start, payload: {}))
|
|
33
|
+
complete(chat) do |chunk|
|
|
34
|
+
# Chunks carrying tool-call fragments have nil/empty content —
|
|
35
|
+
# only text streams.
|
|
36
|
+
text = chunk.content
|
|
37
|
+
on_event.call(Event.new(type: :text_delta, payload: { text: text })) if text.is_a?(String) && !text.empty?
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
complete(chat)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
to_result(response, schema: chat.schema)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# One turn, tools advertised but never run. Streamed and sync return the
|
|
50
|
+
# same Message shape (the stream accumulator builds it), so there is no
|
|
51
|
+
# branch below this point.
|
|
52
|
+
def complete(chat, &block)
|
|
53
|
+
provider_for(chat.model).complete(
|
|
54
|
+
chat.messages,
|
|
55
|
+
tools: chat.tools,
|
|
56
|
+
tool_prefs: chat.tool_prefs,
|
|
57
|
+
temperature: nil,
|
|
58
|
+
model: chat.model,
|
|
59
|
+
schema: chat.schema,
|
|
60
|
+
&block
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Resolved from the model Chat already resolved, so the two can never
|
|
65
|
+
# disagree. Memoised per provider slug: the adapter instance is itself
|
|
66
|
+
# memoised on Silas (and dropped whenever config changes), and building a
|
|
67
|
+
# provider builds a Faraday connection — not something to redo per step.
|
|
68
|
+
# A benign race here costs one extra connection, never correctness.
|
|
69
|
+
def provider_for(model_info)
|
|
70
|
+
@providers ||= {}
|
|
71
|
+
@providers[model_info.provider] ||=
|
|
72
|
+
::RubyLLM::Provider.resolve(model_info.provider).new(::RubyLLM.config)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_chat(context)
|
|
76
|
+
chat = begin
|
|
77
|
+
::RubyLLM.chat(model: context[:model])
|
|
78
|
+
rescue ::RubyLLM::ModelNotFoundError
|
|
79
|
+
raise Silas::Error,
|
|
80
|
+
"Model #{context[:model].inspect} is not in ruby_llm's model registry. " \
|
|
81
|
+
"Newer models may need a registry refresh (`RubyLLM.models.refresh!`), " \
|
|
82
|
+
"or pick a registry-known model in config.default_model / agent.yml."
|
|
83
|
+
end
|
|
84
|
+
chat.with_instructions(context[:system]) if context[:system].present?
|
|
85
|
+
# agent.yml's final_answer schema: with_schema renders the provider's
|
|
86
|
+
# structured-output dialect. The response comes back as a JSON string
|
|
87
|
+
# (Chat#complete would have parsed it for us) — to_result does that.
|
|
88
|
+
chat.with_schema(context[:final_answer]) if context[:final_answer].present?
|
|
89
|
+
# with_tools (plural), not with_tool — 2.0 drops the singular form and
|
|
90
|
+
# the plural exists in both.
|
|
91
|
+
context[:tools].each { |definition| chat.with_tools(SchemaProxy.new(definition)) }
|
|
92
|
+
|
|
93
|
+
replay_history(chat, context[:messages])
|
|
94
|
+
chat
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Rebuild the provider conversation from Silas's canonical rows. The last
|
|
98
|
+
# user message is delivered via ask-equivalent add_message; the whole
|
|
99
|
+
# array goes to the provider on complete.
|
|
100
|
+
def replay_history(chat, messages)
|
|
101
|
+
i = 0
|
|
102
|
+
while i < messages.length
|
|
103
|
+
msg = messages[i]
|
|
104
|
+
case msg[:role]
|
|
105
|
+
when "user"
|
|
106
|
+
chat.add_message(role: :user, content: msg[:content])
|
|
107
|
+
i += 1
|
|
108
|
+
when "assistant"
|
|
109
|
+
chat.add_message(
|
|
110
|
+
role: :assistant,
|
|
111
|
+
content: text_from(msg[:content]),
|
|
112
|
+
tool_calls: tool_calls_from(msg[:content])
|
|
113
|
+
)
|
|
114
|
+
i += 1
|
|
115
|
+
when "tool"
|
|
116
|
+
# Anthropic requires every tool_result for one assistant turn to sit
|
|
117
|
+
# in a single user message. The model can emit parallel tool_use
|
|
118
|
+
# blocks, so batch all consecutive tool results into one Raw message
|
|
119
|
+
# (a one-element batch is the ordinary single-tool-call case).
|
|
120
|
+
first_id = msg[:tool_call_id]
|
|
121
|
+
blocks = []
|
|
122
|
+
while i < messages.length && messages[i][:role] == "tool"
|
|
123
|
+
t = messages[i]
|
|
124
|
+
blocks << {
|
|
125
|
+
type: "tool_result",
|
|
126
|
+
tool_use_id: t[:tool_call_id],
|
|
127
|
+
content: JSON.generate(t[:content])
|
|
128
|
+
}
|
|
129
|
+
i += 1
|
|
130
|
+
end
|
|
131
|
+
chat.add_message(role: :tool, tool_call_id: first_id,
|
|
132
|
+
content: ::RubyLLM::Content::Raw.new(blocks))
|
|
133
|
+
else
|
|
134
|
+
i += 1
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def text_from(blocks)
|
|
140
|
+
Array(blocks).select { |b| b["type"] == "text" }.map { |b| b["text"] }.join
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def tool_calls_from(blocks)
|
|
144
|
+
calls = Array(blocks).select { |b| b["type"] == "tool_call" }
|
|
145
|
+
return nil if calls.empty?
|
|
146
|
+
|
|
147
|
+
calls.to_h do |b|
|
|
148
|
+
[ b["id"], ::RubyLLM::ToolCall.new(id: b["id"], name: b["name"], arguments: b["arguments"]) ]
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def to_result(assistant, schema:)
|
|
153
|
+
blocks = []
|
|
154
|
+
content = structured_content(assistant, schema:)
|
|
155
|
+
if content.is_a?(Hash)
|
|
156
|
+
# with_schema active: persist the parsed payload as its own block type
|
|
157
|
+
# — content.to_s here would write Ruby's Hash#inspect string into the
|
|
158
|
+
# transcript as "text".
|
|
159
|
+
blocks << { "type" => "structured", "data" => content }
|
|
160
|
+
elsif content.to_s.present?
|
|
161
|
+
blocks << { "type" => "text", "text" => content.to_s }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
tool_calls = (assistant.tool_calls || {}).values.map do |tc|
|
|
165
|
+
blocks << { "type" => "tool_call", "id" => tc.id, "name" => tc.name,
|
|
166
|
+
"arguments" => tc.arguments || {} }
|
|
167
|
+
ToolCall.new(id: tc.id, name: tc.name, arguments: (tc.arguments || {}).stringify_keys)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
Result.new(
|
|
171
|
+
blocks: blocks,
|
|
172
|
+
tool_calls: tool_calls,
|
|
173
|
+
stop_reason: tool_calls.any? ? "tool_use" : "end_turn",
|
|
174
|
+
usage: { input_tokens: assistant.tokens&.input, output_tokens: assistant.tokens&.output }
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Chat#complete normally JSON-parses a schema response before handing it
|
|
179
|
+
# back; calling the provider directly means we do it. A response that
|
|
180
|
+
# doesn't parse stays a string rather than raising — a malformed payload
|
|
181
|
+
# is the model's problem to see in the transcript, not a crash.
|
|
182
|
+
def structured_content(assistant, schema:)
|
|
183
|
+
content = assistant.content
|
|
184
|
+
return content unless schema && content.is_a?(String) && !assistant.tool_call?
|
|
185
|
+
|
|
186
|
+
begin
|
|
187
|
+
JSON.parse(content)
|
|
188
|
+
rescue JSON::ParserError
|
|
189
|
+
content
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Carries a Silas tool's schema to the provider. Subclasses RubyLLM::Tool
|
|
194
|
+
# so it satisfies whatever the provider tool-renderers read (today: name,
|
|
195
|
+
# description, params_schema, parameters, provider_params) without Silas
|
|
196
|
+
# having to track that list.
|
|
197
|
+
#
|
|
198
|
+
# It has no #execute on purpose. Nothing calls it — the ledger owns tool
|
|
199
|
+
# execution — and RubyLLM::Tool#execute raises NotImplementedError, so if
|
|
200
|
+
# anything ever did, it fails loudly instead of feeding the model a
|
|
201
|
+
# sentinel.
|
|
202
|
+
class SchemaProxy < ::RubyLLM::Tool
|
|
203
|
+
def initialize(definition)
|
|
204
|
+
super()
|
|
205
|
+
@definition = definition
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def name = @definition["name"]
|
|
209
|
+
def description = @definition["description"]
|
|
210
|
+
|
|
211
|
+
# RubyLLM 1.x reads params_schema; 2.0 renames it parameters_schema
|
|
212
|
+
# (alongside parameters -> declared_parameters and provider_params ->
|
|
213
|
+
# provider_options, which we inherit rather than override). Answering to
|
|
214
|
+
# both is two lines and makes the proxy version-agnostic — confirmed
|
|
215
|
+
# against ruby_llm edge by the CI canary.
|
|
216
|
+
def params_schema = @definition["input_schema"]
|
|
217
|
+
def parameters_schema = @definition["input_schema"]
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
data/lib/silas/channel.rb
CHANGED
|
@@ -57,6 +57,34 @@ module Silas
|
|
|
57
57
|
Rails.application.message_verifier(TOKEN_PURPOSE)
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# A full one-click approve/decline URL for ANY transport — the signed token
|
|
61
|
+
# is the credential, so the link works in a WhatsApp message, a Discord
|
|
62
|
+
# embed, or an SMS exactly as it does in email.
|
|
63
|
+
#
|
|
64
|
+
# Built from the engine's own route set plus the discovered mount point,
|
|
65
|
+
# because a channel runs in a delivery job with no routing scope. The host
|
|
66
|
+
# is required and never guessed: a hostless approval link is a dead link,
|
|
67
|
+
# so this raises with the fix rather than shipping one.
|
|
68
|
+
def self.approval_url(invocation, action, host: nil)
|
|
69
|
+
options = default_url_options.merge(host: host || default_url_options[:host])
|
|
70
|
+
if options[:host].blank?
|
|
71
|
+
raise Error, "Silas::Channel.approval_url needs a host. Set " \
|
|
72
|
+
"config.action_mailer.default_url_options = { host: \"example.com\" } " \
|
|
73
|
+
"(or Rails.application.routes.default_url_options), or pass host:."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
Silas::Engine.routes.url_helpers.channels_approval_url(
|
|
77
|
+
token: token_for(invocation, action),
|
|
78
|
+
script_name: Silas::Inbox.mount_path, **options
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.default_url_options
|
|
83
|
+
mailer = Rails.application.config.action_mailer.default_url_options || {}
|
|
84
|
+
Rails.application.routes.default_url_options.merge(mailer)
|
|
85
|
+
end
|
|
86
|
+
private_class_method :default_url_options
|
|
87
|
+
|
|
60
88
|
# --- outbound interface (subclasses implement) ---
|
|
61
89
|
def deliver_answer(session:, text:)
|
|
62
90
|
raise NotImplementedError, "#{self.class}#deliver_answer"
|
|
@@ -65,5 +93,12 @@ module Silas
|
|
|
65
93
|
def deliver_approval(session:, invocation:)
|
|
66
94
|
raise NotImplementedError, "#{self.class}#deliver_approval"
|
|
67
95
|
end
|
|
96
|
+
|
|
97
|
+
# OPTIONAL: ask_question parks ping this instead of deliver_approval —
|
|
98
|
+
# define it on transports that can collect free text (the question is
|
|
99
|
+
# invocation.arguments["question"]; settle with invocation.answer!).
|
|
100
|
+
# Channels without it are simply not pinged; the question waits in the
|
|
101
|
+
# inbox. Deliberately NOT declared here raising NotImplementedError:
|
|
102
|
+
# respond_to? is the capability check.
|
|
68
103
|
end
|
|
69
104
|
end
|
data/lib/silas/chat.rb
CHANGED
|
@@ -59,11 +59,11 @@ module Silas
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
# The REPL runs inline, in the same process as the loop — so it hears the
|
|
62
|
-
# "silas
|
|
62
|
+
# "delta.silas" notifications and prints tokens as they arrive. Filtered by
|
|
63
63
|
# session id: notifications are process-global.
|
|
64
64
|
def with_delta_stream
|
|
65
65
|
@live = {}
|
|
66
|
-
subscription = ActiveSupport::Notifications.subscribe("silas
|
|
66
|
+
subscription = ActiveSupport::Notifications.subscribe("delta.silas") do |*args|
|
|
67
67
|
payload = args.last
|
|
68
68
|
print_delta(payload) if @session && payload[:session_id] == @session.id
|
|
69
69
|
end
|