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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/LICENSE +21 -0
  4. data/README.md +135 -0
  5. data/app/controllers/silas/channels/approvals_controller.rb +34 -0
  6. data/app/controllers/silas/channels/base_controller.rb +22 -0
  7. data/app/controllers/silas/channels/slack_controller.rb +58 -0
  8. data/app/controllers/silas/inbox/base_controller.rb +37 -0
  9. data/app/controllers/silas/inbox/invocations_controller.rb +44 -0
  10. data/app/controllers/silas/inbox/sessions_controller.rb +16 -0
  11. data/app/helpers/silas/inbox/trace_helper.rb +42 -0
  12. data/app/jobs/silas/agent_loop_job.rb +95 -0
  13. data/app/jobs/silas/channel_delivery_job.rb +62 -0
  14. data/app/jobs/silas/dead_job_rescuer_job.rb +31 -0
  15. data/app/jobs/silas/schedule_job.rb +22 -0
  16. data/app/mailboxes/silas/agent_mailbox.rb +35 -0
  17. data/app/mailers/silas/channel_mailer.rb +17 -0
  18. data/app/models/concerns/silas/inbox/broadcastable.rb +86 -0
  19. data/app/models/silas/application_record.rb +5 -0
  20. data/app/models/silas/session.rb +36 -0
  21. data/app/models/silas/step.rb +33 -0
  22. data/app/models/silas/tool_invocation.rb +78 -0
  23. data/app/models/silas/turn.rb +58 -0
  24. data/app/views/layouts/silas/inbox.html.erb +91 -0
  25. data/app/views/silas/channel_mailer/approval.text.erb +9 -0
  26. data/app/views/silas/channels/approvals/done.html.erb +2 -0
  27. data/app/views/silas/channels/approvals/error.html.erb +2 -0
  28. data/app/views/silas/channels/approvals/show.html.erb +9 -0
  29. data/app/views/silas/inbox/invocations/_approval_card.html.erb +11 -0
  30. data/app/views/silas/inbox/invocations/_invocation.html.erb +11 -0
  31. data/app/views/silas/inbox/sessions/_cost.html.erb +1 -0
  32. data/app/views/silas/inbox/sessions/index.html.erb +23 -0
  33. data/app/views/silas/inbox/sessions/show.html.erb +20 -0
  34. data/app/views/silas/inbox/steps/_step.html.erb +8 -0
  35. data/app/views/silas/inbox/turns/_header.html.erb +7 -0
  36. data/app/views/silas/inbox/turns/_turn.html.erb +8 -0
  37. data/config/routes.rb +19 -0
  38. data/db/migrate/20260714000001_create_silas_tables.rb +78 -0
  39. data/db/migrate/20260715000001_add_channel_outbound_markers.rb +8 -0
  40. data/db/migrate/20260715000002_add_agent_sdk_columns_to_turns.rb +9 -0
  41. data/db/migrate/20260715000003_add_parent_session_to_silas_sessions.rb +8 -0
  42. data/lib/generators/silas/install/install_generator.rb +81 -0
  43. data/lib/generators/silas/install/templates/agent.yml +9 -0
  44. data/lib/generators/silas/install/templates/bin_ci +7 -0
  45. data/lib/generators/silas/install/templates/channel_email.rb +18 -0
  46. data/lib/generators/silas/install/templates/channel_slack.rb +19 -0
  47. data/lib/generators/silas/install/templates/example_eval.rb +22 -0
  48. data/lib/generators/silas/install/templates/example_schedule.md +11 -0
  49. data/lib/generators/silas/install/templates/example_skill.md +8 -0
  50. data/lib/generators/silas/install/templates/example_tool.rb +12 -0
  51. data/lib/generators/silas/install/templates/initializer.rb +16 -0
  52. data/lib/generators/silas/install/templates/instructions.md +4 -0
  53. data/lib/silas/agent.rb +33 -0
  54. data/lib/silas/agent_scope.rb +6 -0
  55. data/lib/silas/agent_sdk/cli.rb +59 -0
  56. data/lib/silas/agent_sdk/stream_parser.rb +86 -0
  57. data/lib/silas/agent_sdk/version_guard.rb +26 -0
  58. data/lib/silas/budget.rb +42 -0
  59. data/lib/silas/channel.rb +69 -0
  60. data/lib/silas/configuration.rb +164 -0
  61. data/lib/silas/connection.rb +77 -0
  62. data/lib/silas/connections.rb +52 -0
  63. data/lib/silas/engine.rb +36 -0
  64. data/lib/silas/engines/agent_sdk.rb +75 -0
  65. data/lib/silas/engines/base.rb +30 -0
  66. data/lib/silas/engines/ruby_llm.rb +136 -0
  67. data/lib/silas/errors.rb +26 -0
  68. data/lib/silas/eval/assertions.rb +107 -0
  69. data/lib/silas/eval/driver.rb +47 -0
  70. data/lib/silas/eval/dsl.rb +55 -0
  71. data/lib/silas/eval/grader.rb +37 -0
  72. data/lib/silas/eval/result.rb +28 -0
  73. data/lib/silas/eval/runner.rb +38 -0
  74. data/lib/silas/eval/scripted_engine.rb +39 -0
  75. data/lib/silas/eval/transcript.rb +22 -0
  76. data/lib/silas/eval.rb +35 -0
  77. data/lib/silas/inbox/cost.rb +58 -0
  78. data/lib/silas/inbox.rb +23 -0
  79. data/lib/silas/instructions.rb +55 -0
  80. data/lib/silas/ledger.rb +178 -0
  81. data/lib/silas/mcp/client.rb +86 -0
  82. data/lib/silas/mcp/handler.rb +89 -0
  83. data/lib/silas/mcp/server.rb +134 -0
  84. data/lib/silas/message_builder.rb +63 -0
  85. data/lib/silas/nested_runner.rb +41 -0
  86. data/lib/silas/registry.rb +155 -0
  87. data/lib/silas/sandbox/docker.rb +67 -0
  88. data/lib/silas/sandbox/null.rb +17 -0
  89. data/lib/silas/sandbox.rb +15 -0
  90. data/lib/silas/schedule/compiler.rb +52 -0
  91. data/lib/silas/schedule.rb +109 -0
  92. data/lib/silas/skill.rb +21 -0
  93. data/lib/silas/slack.rb +55 -0
  94. data/lib/silas/step_runner.rb +98 -0
  95. data/lib/silas/subprocess_runner.rb +41 -0
  96. data/lib/silas/tool.rb +93 -0
  97. data/lib/silas/tools/delegate.rb +42 -0
  98. data/lib/silas/tools/load_skill.rb +25 -0
  99. data/lib/silas/tools/run_code.rb +21 -0
  100. data/lib/silas/version.rb +3 -0
  101. data/lib/silas.rb +144 -0
  102. data/lib/tasks/silas_eval.rake +20 -0
  103. data/lib/tasks/silas_schedules.rake +33 -0
  104. 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,5 @@
1
+ module Silas
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ 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,2 @@
1
+ <h1><%= @action == "approve" ? "Approved" : "Declined" %>.</h1>
2
+ <p>You can close this window.</p>
@@ -0,0 +1,2 @@
1
+ <h1>This link is invalid or has expired.</h1>
2
+ <% if @message %><p><%= @message %></p><% end %>
@@ -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,7 @@
1
+ <div class="turn-head">
2
+ <span class="turn-input"><%= truncate(turn.input, length: 90) %></span>
3
+ <%= status_pill(turn.status) %>
4
+ </div>
5
+ <% if turn.failure_reason.present? %>
6
+ <div class="muted">failed: <%= turn.failure_reason %></div>
7
+ <% end %>
@@ -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