silas 0.1.1
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 +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/app/controllers/silas/channels/approvals_controller.rb +34 -0
- data/app/controllers/silas/channels/base_controller.rb +22 -0
- data/app/controllers/silas/channels/slack_controller.rb +58 -0
- data/app/controllers/silas/inbox/base_controller.rb +37 -0
- data/app/controllers/silas/inbox/invocations_controller.rb +44 -0
- data/app/controllers/silas/inbox/sessions_controller.rb +16 -0
- data/app/helpers/silas/inbox/trace_helper.rb +42 -0
- data/app/jobs/silas/agent_loop_job.rb +95 -0
- data/app/jobs/silas/channel_delivery_job.rb +62 -0
- data/app/jobs/silas/dead_job_rescuer_job.rb +31 -0
- data/app/jobs/silas/schedule_job.rb +22 -0
- data/app/mailboxes/silas/agent_mailbox.rb +35 -0
- data/app/mailers/silas/channel_mailer.rb +17 -0
- data/app/models/concerns/silas/inbox/broadcastable.rb +86 -0
- data/app/models/silas/application_record.rb +5 -0
- data/app/models/silas/session.rb +36 -0
- data/app/models/silas/step.rb +33 -0
- data/app/models/silas/tool_invocation.rb +78 -0
- data/app/models/silas/turn.rb +58 -0
- data/app/views/layouts/silas/inbox.html.erb +91 -0
- data/app/views/silas/channel_mailer/approval.text.erb +9 -0
- data/app/views/silas/channels/approvals/done.html.erb +2 -0
- data/app/views/silas/channels/approvals/error.html.erb +2 -0
- data/app/views/silas/channels/approvals/show.html.erb +9 -0
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +11 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +11 -0
- data/app/views/silas/inbox/sessions/_cost.html.erb +1 -0
- data/app/views/silas/inbox/sessions/index.html.erb +23 -0
- data/app/views/silas/inbox/sessions/show.html.erb +20 -0
- data/app/views/silas/inbox/steps/_step.html.erb +8 -0
- data/app/views/silas/inbox/turns/_header.html.erb +7 -0
- data/app/views/silas/inbox/turns/_turn.html.erb +8 -0
- data/config/routes.rb +19 -0
- data/db/migrate/20260714000001_create_silas_tables.rb +78 -0
- data/db/migrate/20260715000001_add_channel_outbound_markers.rb +8 -0
- data/db/migrate/20260715000002_add_agent_sdk_columns_to_turns.rb +9 -0
- data/db/migrate/20260715000003_add_parent_session_to_silas_sessions.rb +8 -0
- data/lib/generators/silas/install/install_generator.rb +81 -0
- data/lib/generators/silas/install/templates/agent.yml +9 -0
- data/lib/generators/silas/install/templates/bin_ci +7 -0
- data/lib/generators/silas/install/templates/channel_email.rb +18 -0
- data/lib/generators/silas/install/templates/channel_slack.rb +19 -0
- data/lib/generators/silas/install/templates/example_eval.rb +22 -0
- data/lib/generators/silas/install/templates/example_schedule.md +11 -0
- data/lib/generators/silas/install/templates/example_skill.md +8 -0
- data/lib/generators/silas/install/templates/example_tool.rb +12 -0
- data/lib/generators/silas/install/templates/initializer.rb +16 -0
- data/lib/generators/silas/install/templates/instructions.md +4 -0
- data/lib/silas/agent.rb +33 -0
- data/lib/silas/agent_scope.rb +6 -0
- data/lib/silas/agent_sdk/cli.rb +59 -0
- data/lib/silas/agent_sdk/stream_parser.rb +86 -0
- data/lib/silas/agent_sdk/version_guard.rb +26 -0
- data/lib/silas/budget.rb +42 -0
- data/lib/silas/channel.rb +69 -0
- data/lib/silas/configuration.rb +164 -0
- data/lib/silas/connection.rb +77 -0
- data/lib/silas/connections.rb +52 -0
- data/lib/silas/engine.rb +36 -0
- data/lib/silas/engines/agent_sdk.rb +75 -0
- data/lib/silas/engines/base.rb +30 -0
- data/lib/silas/engines/ruby_llm.rb +136 -0
- data/lib/silas/errors.rb +26 -0
- data/lib/silas/eval/assertions.rb +107 -0
- data/lib/silas/eval/driver.rb +47 -0
- data/lib/silas/eval/dsl.rb +55 -0
- data/lib/silas/eval/grader.rb +37 -0
- data/lib/silas/eval/result.rb +28 -0
- data/lib/silas/eval/runner.rb +38 -0
- data/lib/silas/eval/scripted_engine.rb +39 -0
- data/lib/silas/eval/transcript.rb +22 -0
- data/lib/silas/eval.rb +35 -0
- data/lib/silas/inbox/cost.rb +58 -0
- data/lib/silas/inbox.rb +23 -0
- data/lib/silas/instructions.rb +55 -0
- data/lib/silas/ledger.rb +178 -0
- data/lib/silas/mcp/client.rb +86 -0
- data/lib/silas/mcp/handler.rb +89 -0
- data/lib/silas/mcp/server.rb +134 -0
- data/lib/silas/message_builder.rb +63 -0
- data/lib/silas/nested_runner.rb +41 -0
- data/lib/silas/registry.rb +155 -0
- data/lib/silas/sandbox/docker.rb +67 -0
- data/lib/silas/sandbox/null.rb +17 -0
- data/lib/silas/sandbox.rb +15 -0
- data/lib/silas/schedule/compiler.rb +52 -0
- data/lib/silas/schedule.rb +109 -0
- data/lib/silas/skill.rb +21 -0
- data/lib/silas/slack.rb +55 -0
- data/lib/silas/step_runner.rb +98 -0
- data/lib/silas/subprocess_runner.rb +41 -0
- data/lib/silas/tool.rb +93 -0
- data/lib/silas/tools/delegate.rb +42 -0
- data/lib/silas/tools/load_skill.rb +25 -0
- data/lib/silas/tools/run_code.rb +21 -0
- data/lib/silas/version.rb +3 -0
- data/lib/silas.rb +144 -0
- data/lib/tasks/silas_eval.rake +20 -0
- data/lib/tasks/silas_schedules.rake +33 -0
- metadata +232 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# The recurring entry Solid Queue fires enqueues this thin job, which resolves
|
|
3
|
+
# the schedule by name and triggers it. A tick is fire-and-forget: it never
|
|
4
|
+
# retry-loops (a failed tick shouldn't pile up), and the turn it starts is
|
|
5
|
+
# itself fully durable from :prepare onward.
|
|
6
|
+
class ScheduleJob < ActiveJob::Base
|
|
7
|
+
queue_as { Silas.config.queue_name }
|
|
8
|
+
|
|
9
|
+
discard_on(StandardError) do |job, error|
|
|
10
|
+
Rails.logger&.error("silas: schedule #{job.arguments.first} failed: #{error.class}: #{error.message}")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def perform(schedule_name)
|
|
14
|
+
schedule = Silas.schedules.find { |s| s.name == schedule_name }
|
|
15
|
+
unless schedule
|
|
16
|
+
Rails.logger&.warn("silas: no schedule #{schedule_name.inspect} (stale recurring entry? run `bin/rails silas:schedules`)")
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
schedule.trigger!
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# Action Mailbox reference inbound: maps an email thread to a Silas session.
|
|
3
|
+
# A new thread starts a session; a reply (same References/In-Reply-To root)
|
|
4
|
+
# continues it. Route inbound mail here from the host's ApplicationMailbox:
|
|
5
|
+
# routing all: "Silas::AgentMailbox"
|
|
6
|
+
# Requires app/agent/channels/email.rb (Agent::Channels::Email) to exist.
|
|
7
|
+
class AgentMailbox < ActionMailbox::Base
|
|
8
|
+
def process
|
|
9
|
+
channel_class.dispatch(
|
|
10
|
+
thread_key: self.class.thread_key(mail),
|
|
11
|
+
input: body_text,
|
|
12
|
+
metadata: { "email" => { "from" => mail.from&.first, "subject" => mail.subject } }
|
|
13
|
+
)
|
|
14
|
+
rescue Silas::TurnInProgressError
|
|
15
|
+
# A reply arriving while a turn is active is dropped (v1 single-active-turn).
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The thread root: References head, else In-Reply-To, else this Message-ID.
|
|
19
|
+
def self.thread_key(mail)
|
|
20
|
+
(Array(mail.references).first || mail.in_reply_to || mail.message_id).to_s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def channel_class
|
|
26
|
+
Silas.config.channel_resolver&.call("email") or
|
|
27
|
+
raise Silas::Error, "no app/agent/channels/email.rb (Agent::Channels::Email) defined"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def body_text
|
|
31
|
+
part = mail.multipart? ? (mail.text_part || mail.parts.first) : mail
|
|
32
|
+
part&.decoded.to_s.strip
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# Outbound email for the email channel: the agent's answer, and an approval
|
|
3
|
+
# request rendering two signed one-click links.
|
|
4
|
+
class ChannelMailer < ActionMailer::Base
|
|
5
|
+
def answer(to:, subject:, text:)
|
|
6
|
+
@text = text
|
|
7
|
+
mail(to: to, subject: subject) { |f| f.text { render plain: text } }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def approval(to:, subject:, invocation:)
|
|
11
|
+
@invocation = invocation
|
|
12
|
+
@approve_token = Silas::Channel.token_for(invocation, "approve")
|
|
13
|
+
@decline_token = Silas::Channel.token_for(invocation, "decline")
|
|
14
|
+
mail(to: to, subject: subject)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Inbox
|
|
3
|
+
# Live trace: after_commit callbacks broadcast the changed row (rendered as
|
|
4
|
+
# a partial) to the session's Turbo stream. Mirrors ChannelDeliveryJob's
|
|
5
|
+
# after_commit -> off-loop decouple: the `_later` broadcasts render in
|
|
6
|
+
# Turbo's own job, never on the durable loop, and every broadcast is wrapped
|
|
7
|
+
# so a cable/render failure can NEVER roll back or slow a turn.
|
|
8
|
+
#
|
|
9
|
+
# Included in Turn/Step/ToolInvocation always; every callback early-returns
|
|
10
|
+
# unless streaming is actually live, so headless hosts pay nothing.
|
|
11
|
+
module Broadcastable
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
after_create_commit :silas_inbox_broadcast_create
|
|
16
|
+
after_update_commit :silas_inbox_broadcast_update
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def silas_inbox_broadcast_create
|
|
22
|
+
return unless Silas::Inbox.streaming?
|
|
23
|
+
|
|
24
|
+
safe_broadcast do
|
|
25
|
+
case self
|
|
26
|
+
when Silas::Turn
|
|
27
|
+
silas_inbox_dispatch(:append, session_id, target: "silas-turns",
|
|
28
|
+
partial: "silas/inbox/turns/turn", locals: { turn: self })
|
|
29
|
+
when Silas::Step
|
|
30
|
+
silas_inbox_dispatch(:append, turn.session_id, target: "silas-turn-#{turn_id}-steps",
|
|
31
|
+
partial: "silas/inbox/steps/step", locals: { step: self })
|
|
32
|
+
when Silas::ToolInvocation
|
|
33
|
+
silas_inbox_dispatch(:append, turn.session_id, target: "silas-step-#{step_id}-tools",
|
|
34
|
+
partial: "silas/inbox/invocations/invocation", locals: { invocation: self })
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def silas_inbox_broadcast_update
|
|
40
|
+
return unless Silas::Inbox.streaming?
|
|
41
|
+
|
|
42
|
+
safe_broadcast do
|
|
43
|
+
case self
|
|
44
|
+
when Silas::Turn
|
|
45
|
+
next unless saved_change_to_status?
|
|
46
|
+
|
|
47
|
+
silas_inbox_dispatch(:replace, session_id, target: "silas-turn-#{id}-header",
|
|
48
|
+
partial: "silas/inbox/turns/header", locals: { turn: self })
|
|
49
|
+
when Silas::Step
|
|
50
|
+
next unless saved_change_to_status?
|
|
51
|
+
|
|
52
|
+
silas_inbox_dispatch(:replace, turn.session_id, target: ActionView::RecordIdentifier.dom_id(self),
|
|
53
|
+
partial: "silas/inbox/steps/step", locals: { step: self })
|
|
54
|
+
silas_inbox_refresh_cost(turn.session)
|
|
55
|
+
when Silas::ToolInvocation
|
|
56
|
+
next unless saved_change_to_approval_state? || saved_change_to_status?
|
|
57
|
+
|
|
58
|
+
silas_inbox_dispatch(:replace, turn.session_id, target: ActionView::RecordIdentifier.dom_id(self),
|
|
59
|
+
partial: "silas/inbox/invocations/invocation", locals: { invocation: self })
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def silas_inbox_refresh_cost(session)
|
|
65
|
+
silas_inbox_dispatch(:replace, session.id, target: "silas-session-#{session.id}-cost",
|
|
66
|
+
partial: "silas/inbox/sessions/cost", locals: { session: session })
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The single seam over turbo-rails, so the dispatch logic above is
|
|
70
|
+
# testable without loading turbo (stub this method).
|
|
71
|
+
def silas_inbox_dispatch(action, session_id, target:, partial:, locals:)
|
|
72
|
+
stream = Silas::Inbox.stream_name(session_id)
|
|
73
|
+
case action
|
|
74
|
+
when :append then broadcast_append_later_to(stream, target: target, partial: partial, locals: locals)
|
|
75
|
+
when :replace then broadcast_replace_later_to(stream, target: target, partial: partial, locals: locals)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def safe_broadcast
|
|
80
|
+
yield
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
Rails.logger&.warn("[silas.inbox] broadcast failed: #{e.class}: #{e.message}") # never re-raise into the loop
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
class Session < ApplicationRecord
|
|
3
|
+
STATUSES = %w[active archived].freeze
|
|
4
|
+
|
|
5
|
+
has_many :turns, -> { order(:index) }, class_name: "Silas::Turn", foreign_key: :session_id,
|
|
6
|
+
inverse_of: :session, dependent: :destroy
|
|
7
|
+
|
|
8
|
+
validates :status, inclusion: { in: STATUSES }
|
|
9
|
+
validates :agent_name, presence: true
|
|
10
|
+
|
|
11
|
+
def active_turn
|
|
12
|
+
# Fresh relation query, never the cached association — callers mix reads
|
|
13
|
+
# with turn creation in the same objects.
|
|
14
|
+
turns.where(status: Turn::ACTIVE_STATUSES).order(:index).first
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def pending_approvals
|
|
18
|
+
ToolInvocation.joins(:turn).where(silas_turns: { session_id: id }, approval_state: "required")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Enqueue the next turn. One active turn per session — the partial unique
|
|
22
|
+
# index is the backstop; this is the friendly front door.
|
|
23
|
+
def continue(input:)
|
|
24
|
+
if active_turn
|
|
25
|
+
raise TurnInProgressError, "session #{id} already has an active turn (##{active_turn.index})"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
next_index = (turns.maximum(:index) || -1) + 1
|
|
29
|
+
turn = turns.create!(index: next_index, input: input)
|
|
30
|
+
AgentLoopJob.perform_later(turn.id)
|
|
31
|
+
turn
|
|
32
|
+
rescue ActiveRecord::RecordNotUnique
|
|
33
|
+
raise TurnInProgressError, "session #{id} already has an active turn"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
class Step < ApplicationRecord
|
|
3
|
+
STATUSES = %w[started completed].freeze
|
|
4
|
+
|
|
5
|
+
include Silas::Inbox::Broadcastable
|
|
6
|
+
|
|
7
|
+
belongs_to :turn, class_name: "Silas::Turn", inverse_of: :steps
|
|
8
|
+
has_many :tool_invocations, class_name: "Silas::ToolInvocation", foreign_key: :step_id,
|
|
9
|
+
inverse_of: :step, dependent: :destroy
|
|
10
|
+
|
|
11
|
+
validates :status, inclusion: { in: STATUSES }
|
|
12
|
+
validates :index, presence: true
|
|
13
|
+
validate :terminal_write_once, on: :update
|
|
14
|
+
|
|
15
|
+
def completed? = status == "completed"
|
|
16
|
+
|
|
17
|
+
# Any invocation awaiting a human: approval-gated or in-doubt.
|
|
18
|
+
def parked?
|
|
19
|
+
tool_invocations.any? { |i| i.approval_state == "required" || i.status == "in_doubt" }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# THE loop-control column (spike finding #3 generalized): a resumed
|
|
25
|
+
# continuation re-derives its step sequence from terminal, so it must be
|
|
26
|
+
# write-once. NULL -> value is the only legal transition.
|
|
27
|
+
def terminal_write_once
|
|
28
|
+
return unless terminal_changed? && !terminal_was.nil?
|
|
29
|
+
|
|
30
|
+
errors.add(:terminal, "is write-once")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
class ToolInvocation < ApplicationRecord
|
|
3
|
+
STATUSES = %w[pending started completed failed in_doubt].freeze
|
|
4
|
+
EFFECT_MODES = %w[transactional at_most_once idempotent].freeze
|
|
5
|
+
APPROVAL_STATES = [ nil, "required", "approved", "declined", "expired" ].freeze
|
|
6
|
+
|
|
7
|
+
include Silas::Inbox::Broadcastable
|
|
8
|
+
|
|
9
|
+
belongs_to :step, class_name: "Silas::Step", inverse_of: :tool_invocations
|
|
10
|
+
belongs_to :turn, class_name: "Silas::Turn", inverse_of: :tool_invocations
|
|
11
|
+
|
|
12
|
+
validates :status, inclusion: { in: STATUSES }
|
|
13
|
+
validates :effect_mode, inclusion: { in: EFFECT_MODES }
|
|
14
|
+
validates :approval_state, inclusion: { in: APPROVAL_STATES }
|
|
15
|
+
validates :tool_call_id, :tool_name, presence: true
|
|
16
|
+
|
|
17
|
+
def completed? = status == "completed"
|
|
18
|
+
def in_doubt? = status == "in_doubt"
|
|
19
|
+
def awaiting_approval? = approval_state == "required"
|
|
20
|
+
|
|
21
|
+
# Outbound: when a channel-bound invocation parks for approval, ping the
|
|
22
|
+
# channel off-loop (covers both approval-gate and in-doubt parking).
|
|
23
|
+
after_update_commit :notify_channel_approval, if: :should_notify_approval?
|
|
24
|
+
|
|
25
|
+
def should_notify_approval?
|
|
26
|
+
saved_change_to_approval_state? && approval_state == "required" && turn.session.channel.present?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def notify_channel_approval
|
|
30
|
+
ChannelDeliveryJob.perform_later("approval", id)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Approve a parked invocation and resume the turn with a FRESH job (the
|
|
34
|
+
# parked job exited normally; its continuation is consumed). For an
|
|
35
|
+
# in-doubt invocation, approval means "it did not run — re-execute".
|
|
36
|
+
def approve!(by: nil)
|
|
37
|
+
assert_parked!
|
|
38
|
+
update!(status: "pending", approval_state: "approved", approved_by: by)
|
|
39
|
+
resume_turn!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Decline: for an approval gate, eve's shape — the tool is not executed
|
|
43
|
+
# and the model sees {denied: reason} as the result, then the loop
|
|
44
|
+
# continues. For an in-doubt invocation, decline means "assume it ran /
|
|
45
|
+
# abandon" — the operator-supplied reason becomes the recorded outcome.
|
|
46
|
+
def decline!(reason:, by: nil)
|
|
47
|
+
assert_parked!
|
|
48
|
+
update!(status: "failed", approval_state: "declined", approved_by: by,
|
|
49
|
+
decline_reason: reason, result: { "denied" => reason })
|
|
50
|
+
resume_turn!
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Sweep for the rescuer: expire parked invocations past their TTL and fail
|
|
54
|
+
# their turns (parked-forever ghosts are a bug, not a feature).
|
|
55
|
+
def self.expire_stale!(now: Time.current)
|
|
56
|
+
where(approval_state: "required").where(approval_expires_at: ..now).find_each do |inv|
|
|
57
|
+
inv.update!(approval_state: "expired", status: "failed",
|
|
58
|
+
result: { "denied" => "approval expired" })
|
|
59
|
+
inv.turn.finish!(:failed, reason: "approval_expired")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def assert_parked!
|
|
66
|
+
return if awaiting_approval?
|
|
67
|
+
|
|
68
|
+
raise Error, "invocation #{id} is not awaiting approval (state: #{approval_state.inspect})"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def resume_turn!
|
|
72
|
+
return if turn.tool_invocations.where(approval_state: "required").exists?
|
|
73
|
+
|
|
74
|
+
turn.update!(status: "queued")
|
|
75
|
+
AgentLoopJob.perform_later(turn.id)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
class Turn < ApplicationRecord
|
|
3
|
+
STATUSES = %w[queued running waiting in_doubt completed failed canceled].freeze
|
|
4
|
+
ACTIVE_STATUSES = %w[queued running waiting in_doubt].freeze
|
|
5
|
+
|
|
6
|
+
include Silas::Inbox::Broadcastable
|
|
7
|
+
|
|
8
|
+
belongs_to :session, class_name: "Silas::Session", inverse_of: :turns
|
|
9
|
+
has_many :steps, -> { order(:index) }, class_name: "Silas::Step", foreign_key: :turn_id,
|
|
10
|
+
inverse_of: :turn, dependent: :destroy
|
|
11
|
+
has_many :tool_invocations, class_name: "Silas::ToolInvocation", foreign_key: :turn_id,
|
|
12
|
+
inverse_of: :turn, dependent: :destroy
|
|
13
|
+
|
|
14
|
+
validates :status, inclusion: { in: STATUSES }
|
|
15
|
+
validates :index, presence: true
|
|
16
|
+
validate :instructions_snapshot_immutable, on: :update
|
|
17
|
+
|
|
18
|
+
ACTIVE_STATUSES.each { |s| define_method(:"#{s}?") { status == s } }
|
|
19
|
+
def completed? = status == "completed"
|
|
20
|
+
def active? = ACTIVE_STATUSES.include?(status)
|
|
21
|
+
def parked? = status == "waiting" || status == "in_doubt"
|
|
22
|
+
|
|
23
|
+
def finish!(new_status, reason: nil)
|
|
24
|
+
update!(status: new_status.to_s, failure_reason: reason, finished_at: Time.current)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The agent's answer for this turn: the last completed step's text blocks.
|
|
28
|
+
def answer_text
|
|
29
|
+
step = steps.where(status: "completed").order(:index).last
|
|
30
|
+
return "" unless step
|
|
31
|
+
|
|
32
|
+
Array(step.response_blocks).select { |b| b["type"] == "text" }.map { |b| b["text"] }.join
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Outbound: when a channel-bound turn reaches an answer, deliver it off-loop.
|
|
36
|
+
after_update_commit :notify_channel_answer, if: :should_notify_answer?
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def should_notify_answer?
|
|
41
|
+
saved_change_to_status? && (completed? || status == "failed") && session.channel.present?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def notify_channel_answer
|
|
45
|
+
ChannelDeliveryJob.perform_later("answer", id)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# The snapshot is the determinism anchor for the whole turn: once rendered
|
|
51
|
+
# at :prepare it must never change (a resumed job re-reads it).
|
|
52
|
+
def instructions_snapshot_immutable
|
|
53
|
+
return unless instructions_snapshot_changed? && instructions_snapshot_was.present?
|
|
54
|
+
|
|
55
|
+
errors.add(:instructions_snapshot, "is immutable once set")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%= content_for?(:title) ? yield(:title) : "Silas Inbox" %></title>
|
|
7
|
+
<% if Silas::Inbox.streaming_available? %><%= turbo_include_tags rescue nil %><% end %>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #f7f7f8; --panel: #ffffff; --ink: #16181d; --muted: #6b7280;
|
|
11
|
+
--line: #e5e7eb; --accent: #4f46e5; --amber: #b45309; --amber-bg: #fef3c7;
|
|
12
|
+
--green: #047857; --green-bg: #d1fae5; --red: #b91c1c; --red-bg: #fee2e2;
|
|
13
|
+
--blue: #1d4ed8; --blue-bg: #dbeafe; --grey-bg: #eef0f3;
|
|
14
|
+
--radius: 14px; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
15
|
+
}
|
|
16
|
+
@media (prefers-color-scheme: dark) {
|
|
17
|
+
:root {
|
|
18
|
+
--bg: #0d0f14; --panel: #161a22; --ink: #e6e8ec; --muted: #9aa2af;
|
|
19
|
+
--line: #262b36; --accent: #818cf8; --amber: #fbbf24; --amber-bg: #3a2e12;
|
|
20
|
+
--green: #34d399; --green-bg: #10291f; --red: #f87171; --red-bg: #35191a;
|
|
21
|
+
--blue: #60a5fa; --blue-bg: #14233d; --grey-bg: #1e232d;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
* { box-sizing: border-box; }
|
|
25
|
+
body {
|
|
26
|
+
margin: 0; background: var(--bg); color: var(--ink);
|
|
27
|
+
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
28
|
+
-webkit-font-smoothing: antialiased;
|
|
29
|
+
}
|
|
30
|
+
.wrap { max-width: 640px; margin: 0 auto; padding: 20px 16px 64px; }
|
|
31
|
+
header.top { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; }
|
|
32
|
+
header.top .logo { font-weight: 700; letter-spacing: -0.02em; font-size: 20px; }
|
|
33
|
+
header.top .logo a { color: var(--ink); text-decoration: none; }
|
|
34
|
+
header.top .spacer { flex: 1; }
|
|
35
|
+
.badge { background: var(--amber-bg); color: var(--amber); font-weight: 600;
|
|
36
|
+
font-size: 12px; padding: 3px 9px; border-radius: 999px; }
|
|
37
|
+
a { color: var(--accent); }
|
|
38
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
|
|
39
|
+
padding: 14px 16px; margin-bottom: 12px; }
|
|
40
|
+
.session-row { display: block; text-decoration: none; color: inherit; }
|
|
41
|
+
.session-row:hover { border-color: var(--accent); }
|
|
42
|
+
.row-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
|
43
|
+
.row-top .name { font-weight: 600; }
|
|
44
|
+
.muted { color: var(--muted); font-size: 13px; }
|
|
45
|
+
.pill { display: inline-block; font-size: 11px; font-weight: 600; text-transform: uppercase;
|
|
46
|
+
letter-spacing: 0.03em; padding: 2px 8px; border-radius: 999px; }
|
|
47
|
+
.pill-grey { background: var(--grey-bg); color: var(--muted); }
|
|
48
|
+
.pill-blue { background: var(--blue-bg); color: var(--blue); }
|
|
49
|
+
.pill-amber { background: var(--amber-bg); color: var(--amber); }
|
|
50
|
+
.pill-green { background: var(--green-bg); color: var(--green); }
|
|
51
|
+
.pill-red { background: var(--red-bg); color: var(--red); }
|
|
52
|
+
.pill-pulse { animation: pulse 1.4s ease-in-out infinite; }
|
|
53
|
+
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
54
|
+
.turn { border-left: 2px solid var(--line); padding-left: 14px; margin: 16px 0; }
|
|
55
|
+
.turn-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
56
|
+
.turn-input { font-weight: 600; }
|
|
57
|
+
.step { position: relative; margin: 10px 0; padding-left: 14px; }
|
|
58
|
+
.step::before { content: ""; position: absolute; left: -5px; top: 6px; width: 8px; height: 8px;
|
|
59
|
+
border-radius: 50%; background: var(--accent); }
|
|
60
|
+
.step-text { margin: 2px 0; }
|
|
61
|
+
.tool { background: var(--grey-bg); border-radius: 10px; padding: 8px 10px; margin: 6px 0; font-size: 13px; }
|
|
62
|
+
.tool code { font-family: var(--mono); }
|
|
63
|
+
pre { font-family: var(--mono); font-size: 12px; background: var(--grey-bg); border-radius: 8px;
|
|
64
|
+
padding: 8px 10px; overflow-x: auto; margin: 6px 0; }
|
|
65
|
+
.approval { border: 1.5px solid var(--amber); background: var(--amber-bg); border-radius: 12px;
|
|
66
|
+
padding: 12px 14px; margin: 10px 0; }
|
|
67
|
+
.approval h3 { margin: 0 0 8px; font-size: 14px; color: var(--amber); }
|
|
68
|
+
.btn { display: inline-block; border: 0; border-radius: 10px; padding: 11px 18px; min-height: 44px;
|
|
69
|
+
font-size: 15px; font-weight: 600; cursor: pointer; }
|
|
70
|
+
.btn.approve { background: var(--green); color: #fff; }
|
|
71
|
+
.btn.decline { background: var(--panel); color: var(--red); border: 1px solid var(--red); }
|
|
72
|
+
.decline-form textarea { width: 100%; border: 1px solid var(--line); border-radius: 8px;
|
|
73
|
+
padding: 8px; margin: 8px 0; font: inherit; background: var(--panel); color: var(--ink); resize: vertical; }
|
|
74
|
+
form.inline { display: inline; }
|
|
75
|
+
.cost { font-family: var(--mono); font-size: 12px; color: var(--muted); }
|
|
76
|
+
.flash { background: var(--red-bg); color: var(--red); padding: 10px 12px; border-radius: 10px; margin-bottom: 12px; }
|
|
77
|
+
.empty { text-align: center; color: var(--muted); padding: 48px 0; }
|
|
78
|
+
</style>
|
|
79
|
+
</head>
|
|
80
|
+
<body>
|
|
81
|
+
<div class="wrap">
|
|
82
|
+
<header class="top">
|
|
83
|
+
<span class="logo"><%= link_to "Silas", inbox_sessions_path %></span>
|
|
84
|
+
<span class="spacer"></span>
|
|
85
|
+
<% if content_for?(:header_extra) %><%= yield :header_extra %><% end %>
|
|
86
|
+
</header>
|
|
87
|
+
<% if flash[:alert] %><div class="flash"><%= flash[:alert] %></div><% end %>
|
|
88
|
+
<%= yield %>
|
|
89
|
+
</div>
|
|
90
|
+
</body>
|
|
91
|
+
</html>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Your agent needs approval to run: <%= @invocation.tool_name %>
|
|
2
|
+
|
|
3
|
+
Arguments:
|
|
4
|
+
<%= JSON.pretty_generate(@invocation.arguments) %>
|
|
5
|
+
|
|
6
|
+
Approve: <%= silas.approval_url(token: @approve_token) %>
|
|
7
|
+
Decline: <%= silas.approval_url(token: @decline_token) %>
|
|
8
|
+
|
|
9
|
+
These links expire; do not forward them.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<% if @invocation&.awaiting_approval? %>
|
|
2
|
+
<h1>Approval needed: <%= @invocation.tool_name %></h1>
|
|
3
|
+
<pre><%= JSON.pretty_generate(@invocation.arguments) %></pre>
|
|
4
|
+
<%= form_with url: silas.approval_path(token: params[:token]), method: :post do %>
|
|
5
|
+
<button type="submit">Confirm <%= @action %></button>
|
|
6
|
+
<% end %>
|
|
7
|
+
<% else %>
|
|
8
|
+
<h1>This approval is no longer pending.</h1>
|
|
9
|
+
<% end %>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<div class="approval">
|
|
2
|
+
<h3>Approval needed — <%= invocation.tool_name %></h3>
|
|
3
|
+
<pre><%= pretty_args(invocation.arguments) %></pre>
|
|
4
|
+
<%= form_with url: approve_inbox_invocation_path(invocation), method: :post, class: "inline" do %>
|
|
5
|
+
<button class="btn approve">Approve</button>
|
|
6
|
+
<% end %>
|
|
7
|
+
<%= form_with url: decline_inbox_invocation_path(invocation), method: :post, class: "decline-form" do %>
|
|
8
|
+
<textarea name="reason" rows="2" placeholder="Reason (optional — sent back to the agent as the tool result)"></textarea>
|
|
9
|
+
<button class="btn decline">Decline</button>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<div class="tool" id="<%= dom_id(invocation) %>">
|
|
2
|
+
<div>
|
|
3
|
+
<code><%= invocation.tool_name %></code>
|
|
4
|
+
<%= status_pill(invocation.awaiting_approval? ? "required" : invocation.status) %>
|
|
5
|
+
</div>
|
|
6
|
+
<% if invocation.awaiting_approval? %>
|
|
7
|
+
<%= render "silas/inbox/invocations/approval_card", invocation: invocation %>
|
|
8
|
+
<% elsif invocation.result.present? %>
|
|
9
|
+
<pre><%= pretty_args(invocation.result) %></pre>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<span class="cost"><%= cost_label(Silas::Inbox::Cost.for_session(session)) %></span>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<% content_for :header_extra do %>
|
|
2
|
+
<% if @pending_total.positive? %><span class="badge"><%= @pending_total %> awaiting approval</span><% end %>
|
|
3
|
+
<% end %>
|
|
4
|
+
|
|
5
|
+
<% if @sessions.empty? %>
|
|
6
|
+
<div class="empty">No agent sessions yet.</div>
|
|
7
|
+
<% else %>
|
|
8
|
+
<% @sessions.each do |session| %>
|
|
9
|
+
<%= link_to inbox_session_path(session), class: "card session-row" do %>
|
|
10
|
+
<div class="row-top">
|
|
11
|
+
<span class="name"><%= session.agent_name %></span>
|
|
12
|
+
<% if (turn = session.active_turn || session.turns.last) %><%= status_pill(turn.status) %><% end %>
|
|
13
|
+
<% pending = session.pending_approvals.count %>
|
|
14
|
+
<% if pending.positive? %><span class="badge"><%= pending %> to approve</span><% end %>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="muted">
|
|
17
|
+
<%= pluralize(session.turns.count, "turn") %> ·
|
|
18
|
+
<%= session.channel.presence || "direct" %> ·
|
|
19
|
+
<%= relative_time(session.updated_at) %>
|
|
20
|
+
</div>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% end %>
|
|
23
|
+
<% end %>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<% content_for :title, "#{@session.agent_name} · Silas Inbox" %>
|
|
2
|
+
|
|
3
|
+
<% if Silas::Inbox.streaming? %>
|
|
4
|
+
<%= turbo_stream_from Silas::Inbox.stream_name(@session.id) %>
|
|
5
|
+
<% else %>
|
|
6
|
+
<meta http-equiv="refresh" content="4">
|
|
7
|
+
<% end %>
|
|
8
|
+
|
|
9
|
+
<div class="card">
|
|
10
|
+
<div class="row-top">
|
|
11
|
+
<span class="name"><%= @session.agent_name %></span>
|
|
12
|
+
<span class="muted">#<%= @session.id %></span>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="muted"><%= @session.channel.presence || "direct" %> · started <%= relative_time(@session.created_at) %></div>
|
|
15
|
+
<div id="silas-session-<%= @session.id %>-cost"><%= render "silas/inbox/sessions/cost", session: @session %></div>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div id="silas-turns">
|
|
19
|
+
<%= render partial: "silas/inbox/turns/turn", collection: @turns, as: :turn %>
|
|
20
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<div class="step" id="<%= dom_id(step) %>">
|
|
2
|
+
<% if (text = step_text(step)) %>
|
|
3
|
+
<div class="step-text"><%= simple_format(text) %></div>
|
|
4
|
+
<% end %>
|
|
5
|
+
<div id="silas-step-<%= step.id %>-tools">
|
|
6
|
+
<%= render partial: "silas/inbox/invocations/invocation", collection: step.tool_invocations, as: :invocation %>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<div class="turn" id="<%= dom_id(turn) %>">
|
|
2
|
+
<div id="silas-turn-<%= turn.id %>-header">
|
|
3
|
+
<%= render "silas/inbox/turns/header", turn: turn %>
|
|
4
|
+
</div>
|
|
5
|
+
<div id="silas-turn-<%= turn.id %>-steps">
|
|
6
|
+
<%= render partial: "silas/inbox/steps/step", collection: turn.steps, as: :step %>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Silas::Engine.routes.draw do
|
|
2
|
+
namespace :channels do
|
|
3
|
+
post "slack/events", to: "slack#events"
|
|
4
|
+
post "slack/actions", to: "slack#actions"
|
|
5
|
+
get "approvals/:token", to: "approvals#show", as: :approval
|
|
6
|
+
post "approvals/:token", to: "approvals#update"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
namespace :inbox do
|
|
10
|
+
root to: "sessions#index"
|
|
11
|
+
resources :sessions, only: %i[index show]
|
|
12
|
+
resources :invocations, only: [] do
|
|
13
|
+
member do
|
|
14
|
+
post :approve
|
|
15
|
+
post :decline
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|