silas 0.1.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -0
  3. data/README.md +81 -14
  4. data/app/controllers/concerns/silas/api/serialization.rb +63 -0
  5. data/app/controllers/silas/api/base_controller.rb +24 -0
  6. data/app/controllers/silas/api/v1/approvals_controller.rb +35 -0
  7. data/app/controllers/silas/api/v1/sessions_controller.rb +39 -0
  8. data/app/controllers/silas/api/v1/streams_controller.rb +117 -0
  9. data/app/controllers/silas/api/v1/turns_controller.rb +32 -0
  10. data/app/controllers/silas/inbox/sessions_controller.rb +39 -2
  11. data/app/controllers/silas/inbox/turns_controller.rb +51 -0
  12. data/app/helpers/silas/inbox/trace_helper.rb +5 -0
  13. data/app/jobs/silas/agent_loop_job.rb +46 -43
  14. data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
  15. data/app/models/silas/session.rb +6 -0
  16. data/app/models/silas/tool_invocation.rb +13 -1
  17. data/app/models/silas/turn.rb +10 -0
  18. data/app/views/layouts/silas/inbox.html.erb +20 -0
  19. data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
  20. data/app/views/silas/inbox/sessions/index.html.erb +31 -5
  21. data/app/views/silas/inbox/sessions/show.html.erb +11 -0
  22. data/app/views/silas/inbox/steps/_step.html.erb +9 -1
  23. data/app/views/silas/inbox/turns/_header.html.erb +19 -1
  24. data/config/routes.rb +28 -1
  25. data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
  26. data/db/migrate/20260725000001_add_provider_to_silas_steps.rb +13 -0
  27. data/lib/generators/silas/install/install_generator.rb +34 -14
  28. data/lib/generators/silas/install/templates/agent.yml +10 -1
  29. data/lib/generators/silas/install/templates/bin_ci +2 -2
  30. data/lib/generators/silas/install/templates/initializer.rb +30 -5
  31. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
  32. data/lib/silas/agent.rb +4 -0
  33. data/lib/silas/chat.rb +47 -13
  34. data/lib/silas/configuration.rb +77 -37
  35. data/lib/silas/delta_buffer.rb +50 -0
  36. data/lib/silas/doctor.rb +155 -0
  37. data/lib/silas/engine.rb +6 -0
  38. data/lib/silas/engines/base.rb +6 -8
  39. data/lib/silas/engines/ruby_llm.rb +28 -6
  40. data/lib/silas/errors.rb +3 -3
  41. data/lib/silas/eval/assertions.rb +18 -0
  42. data/lib/silas/eval/scripted_engine.rb +0 -2
  43. data/lib/silas/eval/transcript.rb +1 -0
  44. data/lib/silas/inbox/cost.rb +37 -13
  45. data/lib/silas/inbox/delta_broadcaster.rb +38 -0
  46. data/lib/silas/ledger.rb +26 -10
  47. data/lib/silas/mcp/handler.rb +6 -5
  48. data/lib/silas/mcp/server.rb +9 -9
  49. data/lib/silas/message_builder.rb +9 -2
  50. data/lib/silas/named_agent.rb +1 -0
  51. data/lib/silas/registry.rb +18 -8
  52. data/lib/silas/step_runner.rb +33 -6
  53. data/lib/silas/tool.rb +5 -0
  54. data/lib/silas/version.rb +1 -1
  55. data/lib/silas.rb +10 -9
  56. data/lib/tasks/silas_doctor.rake +17 -0
  57. metadata +14 -6
  58. data/lib/silas/agent_sdk/cli.rb +0 -59
  59. data/lib/silas/agent_sdk/stream_parser.rb +0 -86
  60. data/lib/silas/agent_sdk/version_guard.rb +0 -26
  61. data/lib/silas/engines/agent_sdk.rb +0 -75
  62. data/lib/silas/subprocess_runner.rb +0 -41
@@ -1,9 +1,33 @@
1
1
  module Silas
2
2
  module Inbox
3
3
  class SessionsController < BaseController
4
+ before_action :authenticate_write!, only: :create
5
+
6
+ PER_PAGE = 50
7
+
4
8
  def index
5
- @sessions = Silas::Session.order(created_at: :desc).limit(100)
6
- @sessions = @sessions.where(agent_name: params[:agent]) if params[:agent].present?
9
+ # Keyset pagination on id (?before=<id> pages older), newest first.
10
+ scope = Silas::Session.order(id: :desc)
11
+ scope = scope.where(agent_name: params[:agent]) if params[:agent].present?
12
+ if params[:pending].present? # the "N awaiting approval" badge drills into this
13
+ # Subquery, not joins+distinct: DISTINCT over silas_sessions.* trips
14
+ # on the json metadata column (PG json has no equality operator).
15
+ scope = scope.where(
16
+ id: Silas::Turn.joins(:tool_invocations)
17
+ .where(silas_tool_invocations: { approval_state: "required" })
18
+ .select(:session_id)
19
+ )
20
+ end
21
+ scope = scope.where(id: ...params[:before].to_i) if params[:before].present?
22
+
23
+ # One query for the rows + turns, one for the pending counts — the
24
+ # per-row active_turn/turns.last/counts pattern was ~4 queries a card.
25
+ @sessions = scope.limit(PER_PAGE).includes(:turns).to_a
26
+ @next_before = @sessions.last&.id if @sessions.size == PER_PAGE
27
+ @pending_counts = Silas::ToolInvocation.joins(:turn)
28
+ .where(approval_state: "required",
29
+ silas_turns: { session_id: @sessions.map(&:id) })
30
+ .group("silas_turns.session_id").count
7
31
  @agent_names = Silas::Session.distinct.pluck(:agent_name).sort
8
32
  @pending_total = Silas::ToolInvocation.where(approval_state: "required").count
9
33
  end
@@ -13,6 +37,19 @@ module Silas
13
37
  @turns = @session.turns.includes(steps: :tool_invocations)
14
38
  @cost = Silas::Inbox::Cost.for_session(@session)
15
39
  end
40
+
41
+ # Start a session from the browser. channel stays nil ("direct") — web
42
+ # chat is read live on the session page, not delivered outbound.
43
+ def create
44
+ input = params[:input].to_s.strip
45
+ return redirect_to inbox_sessions_path, alert: "Type a message first." if input.empty?
46
+
47
+ handle = params[:agent].present? ? Silas.agent(params[:agent]) : Silas.agent
48
+ started = handle.start(input: input)
49
+ redirect_to inbox_session_path(started)
50
+ rescue Silas::Error => e
51
+ redirect_to inbox_sessions_path, alert: e.message
52
+ end
16
53
  end
17
54
  end
18
55
  end
@@ -0,0 +1,51 @@
1
+ module Silas
2
+ module Inbox
3
+ # The web composer: append a turn to an existing session. Same write-auth
4
+ # gate as approve/decline. Deliberately thin — the turn runs on the durable
5
+ # loop and the model's after_commit broadcasts render it live, so this
6
+ # controller only enqueues and redirects.
7
+ class TurnsController < BaseController
8
+ before_action :authenticate_write!
9
+
10
+ def create
11
+ agent_session = Silas::Session.find(params[:session_id])
12
+ input = params[:input].to_s.strip
13
+ return redirect_to inbox_session_path(agent_session), alert: "Type a message first." if input.empty?
14
+
15
+ agent_session.continue(input: input)
16
+ redirect_to inbox_session_path(agent_session)
17
+ rescue Silas::TurnInProgressError
18
+ redirect_to inbox_session_path(agent_session), alert: "A turn is already running — wait for it to settle."
19
+ end
20
+
21
+ # Cancel from the trace. Running turns are flagged and honored at the
22
+ # next step boundary (the same safe point as budgets); parked/queued
23
+ # turns cancel immediately.
24
+ def cancel
25
+ turn = Silas::Turn.find(params[:id])
26
+ outcome = turn.cancel!(reason: "canceled from inbox by #{current_actor}")
27
+ notice = outcome == :cancel_requested ? "Cancel requested — honored at the next step boundary." : "Turn canceled."
28
+ redirect_to inbox_session_path(turn.session_id), notice: notice
29
+ end
30
+
31
+ # Top up a budget-parked turn and resume it — the CHANGELOG promised
32
+ # this card in 0.1.5; completed steps replay from rows, same as approvals.
33
+ def raise_budget
34
+ turn = Silas::Turn.find(params[:id])
35
+ unless turn.budget_parked?
36
+ return redirect_to inbox_session_path(turn.session_id),
37
+ alert: "Turn ##{turn.index} is not budget-parked."
38
+ end
39
+
40
+ reason = turn.failure_reason.to_s
41
+ value = params[:value].to_s
42
+ numeric = reason == "max_cost" ? value.to_f : value.to_i
43
+
44
+ turn.raise_budget!(**{ reason.to_sym => numeric })
45
+ redirect_to inbox_session_path(turn.session_id), notice: "#{reason} raised — resuming."
46
+ rescue Silas::Error, ArgumentError => e
47
+ redirect_to inbox_session_path(turn.session_id), alert: e.message
48
+ end
49
+ end
50
+ end
51
+ end
@@ -18,6 +18,11 @@ module Silas
18
18
  Array(step.response_blocks).select { |b| b["type"] == "text" }.map { |b| b["text"] }.join.presence
19
19
  end
20
20
 
21
+ # The final_answer payload, when the agent declared a schema.
22
+ def step_structured(step)
23
+ Array(step.response_blocks).reverse.find { |b| b["type"] == "structured" }&.dig("data")
24
+ end
25
+
21
26
  def pretty_args(hash)
22
27
  JSON.pretty_generate(hash || {})
23
28
  end
@@ -12,10 +12,51 @@ module Silas
12
12
  class AgentLoopJob < ActiveJob::Base
13
13
  include ActiveJob::Continuable
14
14
 
15
+ # Errors must reach retry_on. By default Continuable swallows any
16
+ # StandardError raised after a checkpoint and silently self-resumes —
17
+ # unbounded invisible retries that bypass attempts/wait/jitter entirely
18
+ # (verified against activejob 8.1: the around_perform rescue runs before
19
+ # rescue_with_handler). Checkpoints still survive retry_on's re-enqueues —
20
+ # continuation state rides the job payload — so a retried execution skips
21
+ # completed steps. Isolation interrupts are rescued separately and are
22
+ # unaffected by this flag. Do NOT reach for max_resumptions as an error
23
+ # bound: with isolate_steps on, every isolated step consumes one
24
+ # resumption by design.
25
+ self.resume_errors_after_advancing = false
26
+
15
27
  self.resume_options = { wait: 0 } # spike: default 5s wait makes turns crawl
16
28
 
17
29
  queue_as { Silas.config.queue_name }
18
30
 
31
+ # Transient provider trouble: back off and retry; the continuation resumes
32
+ # from the last completed step. Exhaustion fails the turn LOUDLY — a turn
33
+ # must never strand in "running". (Never retry_on StandardError: it would
34
+ # catch Continuation::Error subclasses and retry a structurally broken job
35
+ # forever.)
36
+ retry_on ::RubyLLM::RateLimitError, ::RubyLLM::OverloadedError,
37
+ ::RubyLLM::ServiceUnavailableError, ::RubyLLM::ServerError,
38
+ ::Faraday::TimeoutError, ::Faraday::ConnectionFailed,
39
+ wait: :polynomially_longer, attempts: 5, jitter: 0.15 do |job, error|
40
+ fail_turn(job, error)
41
+ end
42
+
43
+ # Permanent provider rejections: retrying cannot help. Fail the turn now.
44
+ discard_on ::RubyLLM::UnauthorizedError, ::RubyLLM::PaymentRequiredError,
45
+ ::RubyLLM::ForbiddenError, ::RubyLLM::BadRequestError do |job, error|
46
+ fail_turn(job, error)
47
+ end
48
+
49
+ # The force-fail path: expire approvals FIRST so no stale card can
50
+ # zombie-resume the failed turn, then finish loudly.
51
+ def self.fail_turn(job, error)
52
+ turn = Turn.find_by(id: job.arguments.first)
53
+ return unless turn&.active?
54
+
55
+ turn.expire_pending_approvals!("turn failed: model error")
56
+ turn.finish!(:failed, reason: "model_error")
57
+ Rails.logger&.error("[silas] turn #{turn.id} failed on #{error.class}: #{error.message}")
58
+ end
59
+
19
60
  def perform(turn_id)
20
61
  turn = Turn.find(turn_id)
21
62
  return if turn.completed? || %w[failed canceled].include?(turn.status)
@@ -26,26 +67,17 @@ module Silas
26
67
  # staff member never wakes up holding the root agent's tools.
27
68
  scope = Silas.scope_for_session(turn.session)
28
69
  if scope
29
- Silas.with_agent_scope(scope) { drive(turn) }
70
+ Silas.with_agent_scope(scope) { run_turn(turn) }
30
71
  else
31
- drive(turn)
72
+ run_turn(turn)
32
73
  end
33
74
  end
34
75
 
35
76
  private
36
77
 
37
- def drive(turn)
38
- if Silas.resolved_engine.class.loop_ownership == :engine
39
- perform_engine_owned(turn)
40
- else
41
- perform_framework_owned(turn)
42
- end
43
- end
44
-
45
-
46
- # :ruby_llm — the framework drives the loop, one model call per step, tools
47
- # executed through the Ledger. The determinism constraints live here.
48
- def perform_framework_owned(turn)
78
+ # The framework drives the loop: one model call per step, tools executed
79
+ # through the Ledger. The determinism constraints live here.
80
+ def run_turn(turn)
49
81
  step :prepare, isolated: isolate? do
50
82
  Ledger.assert_no_checkpoint!
51
83
  turn.update!(status: "running", job_id: job_id, started_at: turn.started_at || Time.current)
@@ -95,35 +127,6 @@ module Silas
95
127
  end
96
128
  end
97
129
 
98
- # :agent_sdk — Claude Code owns the loop; one isolated :run step wraps the
99
- # whole subprocess (one Continuation checkpoint per invocation). Same
100
- # durable shell, same queue/rescuer/single-active-turn invariants.
101
- def perform_engine_owned(turn)
102
- step :prepare, isolated: isolate? do
103
- Ledger.assert_no_checkpoint!
104
- turn.update!(status: "running", job_id: job_id, started_at: turn.started_at || Time.current)
105
- Instructions.snapshot!(turn)
106
- Step.find_or_create_by!(turn: turn, index: 0) # anchor step exists before the MCP thread needs it
107
- end
108
-
109
- # Cancellation for engine-owned turns is honored only BEFORE the
110
- # subprocess starts — a running claude -p is not aborted mid-flight (v1).
111
- if turn.reload.cancel_requested_at
112
- turn.finish!(:canceled, reason: "canceled")
113
- return
114
- end
115
-
116
- outcome = nil
117
- step :run, isolated: isolate? do
118
- Ledger.assert_no_checkpoint!
119
- outcome = SubprocessRunner.call(turn)
120
- end
121
-
122
- step :finalize do
123
- turn.finish!(:completed) if outcome == :terminal
124
- end
125
- end
126
-
127
130
  def isolate? = Silas.config.isolate_steps
128
131
  end
129
132
  end
@@ -20,12 +20,33 @@ module Silas
20
20
 
21
21
  rescued = 0
22
22
  SolidQueue::FailedExecution.includes(:job).find_each do |failed|
23
- next unless DEAD_PROCESS_ERRORS.include?(failed.error&.dig("exception_class"))
24
-
25
- failed.retry
26
- rescued += 1
23
+ if DEAD_PROCESS_ERRORS.include?(failed.error&.dig("exception_class"))
24
+ failed.retry
25
+ rescued += 1
26
+ elsif failed.job&.class_name == "Silas::AgentLoopJob"
27
+ fail_stranded_turn(failed)
28
+ end
27
29
  end
28
30
  rescued
29
31
  end
32
+
33
+ private
34
+
35
+ # A loop job that failed with a NON-dead-process error (something outside
36
+ # AgentLoopJob's retry list — a NoMethodError in a tool, an AR blip) will
37
+ # never be retried by anyone. Without this sweep its turn sits in
38
+ # "running" forever: not failed, not parked, invisible as broken. The
39
+ # failed execution stays in Solid Queue for forensics; the TURN is failed
40
+ # loudly with its approvals expired.
41
+ def fail_stranded_turn(failed)
42
+ turn = Turn.find_by(id: failed.job.arguments&.dig("arguments", 0))
43
+ return unless turn&.active?
44
+
45
+ exception = failed.error&.dig("exception_class")
46
+ turn.expire_pending_approvals!("turn failed: #{exception}")
47
+ turn.finish!(:failed, reason: "job_failed")
48
+ Rails.logger&.error("[silas] turn #{turn.id} failed: its loop job died with " \
49
+ "#{exception} — #{failed.error&.dig('message')}")
50
+ end
30
51
  end
31
52
  end
@@ -5,6 +5,12 @@ module Silas
5
5
  has_many :turns, -> { order(:index) }, class_name: "Silas::Turn", foreign_key: :session_id,
6
6
  inverse_of: :session, dependent: :destroy
7
7
 
8
+ # Delegations and handoffs stamp parent_session_id; without associations
9
+ # they surfaced as unexplained orphans.
10
+ belongs_to :parent_session, class_name: "Silas::Session", optional: true
11
+ has_many :child_sessions, class_name: "Silas::Session",
12
+ foreign_key: :parent_session_id, inverse_of: :parent_session
13
+
8
14
  validates :status, inclusion: { in: STATUSES }
9
15
  validates :agent_name, presence: true
10
16
 
@@ -35,6 +35,7 @@ module Silas
35
35
  # in-doubt invocation, approval means "it did not run — re-execute".
36
36
  def approve!(by: nil)
37
37
  assert_parked!
38
+ assert_turn_resumable!
38
39
  update!(status: "pending", approval_state: "approved", approved_by: by)
39
40
  resume_turn!
40
41
  end
@@ -45,6 +46,7 @@ module Silas
45
46
  # abandon" — the operator-supplied reason becomes the recorded outcome.
46
47
  def decline!(reason:, by: nil)
47
48
  assert_parked!
49
+ assert_turn_resumable!
48
50
  update!(status: "failed", approval_state: "declined", approved_by: by,
49
51
  decline_reason: reason, result: { "denied" => reason })
50
52
  resume_turn!
@@ -68,8 +70,18 @@ module Silas
68
70
  raise Error, "invocation #{id} is not awaiting approval (state: #{approval_state.inspect})"
69
71
  end
70
72
 
73
+ # A failed turn must never be zombie-resumed by a stale approval card:
74
+ # force-fail paths expire approvals first, but a card already rendered in
75
+ # someone's browser can still POST — the verdict must land on a live turn.
76
+ def assert_turn_resumable!
77
+ return unless turn.reload.failed?
78
+
79
+ raise Error, "turn #{turn.id} already failed (#{turn.failure_reason}) — " \
80
+ "this approval can no longer resume it"
81
+ end
82
+
71
83
  def resume_turn!
72
- return if turn.reload.canceled? # a canceled turn never zombie-resumes
84
+ return if turn.reload.canceled? || turn.failed? # settled turns never zombie-resume
73
85
  return if turn.tool_invocations.where(approval_state: "required").exists?
74
86
 
75
87
  turn.update!(status: "queued")
@@ -17,6 +17,7 @@ module Silas
17
17
 
18
18
  ACTIVE_STATUSES.each { |s| define_method(:"#{s}?") { status == s } }
19
19
  def completed? = status == "completed"
20
+ def failed? = status == "failed"
20
21
  def active? = ACTIVE_STATUSES.include?(status)
21
22
  def parked? = status == "waiting" || status == "in_doubt"
22
23
 
@@ -83,6 +84,15 @@ module Silas
83
84
  Array(step.response_blocks).select { |b| b["type"] == "text" }.map { |b| b["text"] }.join
84
85
  end
85
86
 
87
+ # The structured payload when the agent declares a final_answer schema in
88
+ # agent.yml (nil otherwise) — the parsed Hash, not a string to re-parse.
89
+ def answer_data
90
+ step = steps.where(status: "completed").order(:index).last
91
+ return nil unless step
92
+
93
+ Array(step.response_blocks).reverse.find { |b| b["type"] == "structured" }&.dig("data")
94
+ end
95
+
86
96
  # Outbound: when a channel-bound turn reaches an answer, deliver it off-loop.
87
97
  after_update_commit :notify_channel_answer, if: :should_notify_answer?
88
98
 
@@ -58,6 +58,7 @@
58
58
  .step::before { content: ""; position: absolute; left: -5px; top: 6px; width: 8px; height: 8px;
59
59
  border-radius: 50%; background: var(--accent); }
60
60
  .step-text { margin: 2px 0; }
61
+ .step-live { white-space: pre-wrap; }
61
62
  .tool { background: var(--grey-bg); border-radius: 10px; padding: 8px 10px; margin: 6px 0; font-size: 13px; }
62
63
  .tool code { font-family: var(--mono); }
63
64
  pre { font-family: var(--mono); font-size: 12px; background: var(--grey-bg); border-radius: 8px;
@@ -73,12 +74,30 @@
73
74
  padding: 8px; margin: 8px 0; font: inherit; background: var(--panel); color: var(--ink); resize: vertical; }
74
75
  form.inline { display: inline; }
75
76
  .cost { font-family: var(--mono); font-size: 12px; color: var(--muted); }
77
+ .composer textarea { width: 100%; border: 1px solid var(--line); border-radius: 10px;
78
+ padding: 10px 12px; font: inherit; background: var(--panel); color: var(--ink); resize: vertical; }
79
+ .composer textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
80
+ .composer-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; }
81
+ .btn.send { background: var(--accent); color: #fff; }
82
+ select.composer-agent { border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px;
83
+ font: inherit; background: var(--panel); color: var(--ink); margin-bottom: 8px; }
76
84
  .flash { background: var(--red-bg); color: var(--red); padding: 10px 12px; border-radius: 10px; margin-bottom: 12px; }
85
+ .flash-notice { background: var(--green-bg); color: var(--green); }
86
+ .btn-cancel { border: 1px solid var(--red); background: transparent; color: var(--red);
87
+ border-radius: 8px; padding: 2px 10px; font-size: 12px; font-weight: 600; cursor: pointer; margin-left: auto; }
88
+ pre.error { background: var(--red-bg); color: var(--red); }
89
+ pre.args { opacity: 0.85; }
77
90
  .empty { text-align: center; color: var(--muted); padding: 48px 0; }
78
91
  .agent-filter { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
79
92
  .agent-filter .chip { font-size: 12px; padding: 3px 10px; border: 1px solid #d9dce1;
80
93
  border-radius: 999px; text-decoration: none; color: inherit; }
81
94
  .agent-filter .chip-on { background: #16181d; color: #fff; border-color: #16181d; }
95
+ .pager { text-align: center; margin: 16px 0; }
96
+ .pager .chip { font-size: 13px; padding: 6px 14px; border: 1px solid var(--line);
97
+ border-radius: 999px; text-decoration: none; color: inherit; }
98
+ .topup-form { display: flex; gap: 8px; margin-top: 8px; }
99
+ .topup-input { flex: 1; border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px;
100
+ font: inherit; background: var(--panel); color: var(--ink); }
82
101
  </style>
83
102
  </head>
84
103
  <body>
@@ -89,6 +108,7 @@
89
108
  <% if content_for?(:header_extra) %><%= yield :header_extra %><% end %>
90
109
  </header>
91
110
  <% if flash[:alert] %><div class="flash"><%= flash[:alert] %></div><% end %>
111
+ <% if flash[:notice] %><div class="flash flash-notice"><%= flash[:notice] %></div><% end %>
92
112
  <%= yield %>
93
113
  </div>
94
114
  </body>
@@ -2,10 +2,26 @@
2
2
  <div>
3
3
  <code><%= invocation.tool_name %></code>
4
4
  <%= status_pill(invocation.awaiting_approval? ? "required" : invocation.status) %>
5
+ <%# The audit line — who held the lever, and why it moved. %>
6
+ <% if invocation.approval_state == "approved" && invocation.approved_by.present? %>
7
+ <span class="muted">approved by <%= invocation.approved_by %></span>
8
+ <% elsif invocation.approval_state == "declined" %>
9
+ <span class="muted">declined<%= " by #{invocation.approved_by}" if invocation.approved_by.present? %><%= " — “#{invocation.decline_reason}”" if invocation.decline_reason.present? %></span>
10
+ <% elsif invocation.approval_state == "expired" %>
11
+ <span class="muted">approval expired unanswered</span>
12
+ <% end %>
5
13
  </div>
6
14
  <% if invocation.awaiting_approval? %>
7
15
  <%= render "silas/inbox/invocations/approval_card", invocation: invocation %>
8
- <% elsif invocation.result.present? %>
9
- <pre><%= pretty_args(invocation.result) %></pre>
16
+ <% else %>
17
+ <%# What the agent passed — the question an audit surface must answer. %>
18
+ <% if invocation.arguments.present? %>
19
+ <pre class="args"><%= pretty_args(invocation.arguments) %></pre>
20
+ <% end %>
21
+ <% if invocation.error.present? %>
22
+ <pre class="error"><%= invocation.error %></pre>
23
+ <% elsif invocation.result.present? %>
24
+ <pre><%= pretty_args(invocation.result) %></pre>
25
+ <% end %>
10
26
  <% end %>
11
27
  </div>
@@ -1,5 +1,7 @@
1
1
  <% content_for :header_extra do %>
2
- <% if @pending_total.positive? %><span class="badge"><%= @pending_total %> awaiting approval</span><% end %>
2
+ <% if @pending_total.positive? %>
3
+ <%= link_to "#{@pending_total} awaiting approval", inbox_sessions_path(pending: 1), class: "badge" %>
4
+ <% end %>
3
5
  <% end %>
4
6
 
5
7
  <% if @agent_names && @agent_names.size > 1 %>
@@ -12,22 +14,46 @@
12
14
  </div>
13
15
  <% end %>
14
16
 
17
+ <div class="card composer">
18
+ <%= form_with url: inbox_sessions_path do |f| %>
19
+ <% roster = Silas.named_agent_scopes.keys.sort %>
20
+ <% if roster.any? %>
21
+ <%= f.select :agent, [ [ "agent (root)", "" ] ] + roster.map { |a| [ a, a ] },
22
+ {}, class: "composer-agent" %>
23
+ <% end %>
24
+ <%= f.text_area :input, rows: 2, required: true, class: "composer-input",
25
+ placeholder: "Start a new session…" %>
26
+ <div class="composer-row">
27
+ <span></span>
28
+ <%= f.submit "Start session", class: "btn send" %>
29
+ </div>
30
+ <% end %>
31
+ </div>
32
+
15
33
  <% if @sessions.empty? %>
16
- <div class="empty">No agent sessions yet.</div>
34
+ <div class="empty">No agent sessions yet — start one above.</div>
17
35
  <% else %>
18
36
  <% @sessions.each do |session| %>
19
37
  <%= link_to inbox_session_path(session), class: "card session-row" do %>
20
38
  <div class="row-top">
21
39
  <span class="name"><%= session.agent_name %></span>
22
- <% if (turn = session.active_turn || session.turns.last) %><%= status_pill(turn.status) %><% end %>
23
- <% pending = session.pending_approvals.count %>
40
+ <%# Loaded association no per-row queries. %>
41
+ <% if (turn = session.turns.detect(&:active?) || session.turns.last) %><%= status_pill(turn.status) %><% end %>
42
+ <% pending = @pending_counts.fetch(session.id, 0) %>
24
43
  <% if pending.positive? %><span class="badge"><%= pending %> to approve</span><% end %>
25
44
  </div>
26
45
  <div class="muted">
27
- <%= pluralize(session.turns.count, "turn") %> ·
46
+ <%= pluralize(session.turns.size, "turn") %> ·
28
47
  <%= session.channel.presence || "direct" %> ·
29
48
  <%= relative_time(session.updated_at) %>
30
49
  </div>
31
50
  <% end %>
32
51
  <% end %>
52
+ <% if @next_before %>
53
+ <div class="pager">
54
+ <%= link_to "Older sessions →",
55
+ inbox_sessions_path(before: @next_before, agent: params[:agent].presence, pending: params[:pending].presence),
56
+ class: "chip" %>
57
+ </div>
58
+ <% end %>
33
59
  <% end %>
@@ -18,3 +18,14 @@
18
18
  <div id="silas-turns">
19
19
  <%= render partial: "silas/inbox/turns/turn", collection: @turns, as: :turn %>
20
20
  </div>
21
+
22
+ <div class="card composer">
23
+ <%= form_with url: inbox_session_turns_path(@session) do |f| %>
24
+ <%= f.text_area :input, rows: 2, required: true, class: "composer-input",
25
+ placeholder: "Message #{@session.agent_name}…" %>
26
+ <div class="composer-row">
27
+ <span class="muted"><%= "a turn is running — sending will queue behind it" if @session.active_turn %></span>
28
+ <%= f.submit "Send", class: "btn send" %>
29
+ </div>
30
+ <% end %>
31
+ </div>
@@ -1,6 +1,14 @@
1
1
  <div class="step" id="<%= dom_id(step) %>">
2
+ <%# The text container is unconditional: silas-step-<id>-text is the live
3
+ delta target. While the step runs, deltas replace its innerHTML; the
4
+ completed row render (this partial, re-broadcast) supersedes them. %>
2
5
  <% if (text = step_text(step)) %>
3
- <div class="step-text"><%= simple_format(text) %></div>
6
+ <div class="step-text" id="silas-step-<%= step.id %>-text"><%= simple_format(text) %></div>
7
+ <% else %>
8
+ <div class="step-text step-live" id="silas-step-<%= step.id %>-text"></div>
9
+ <% end %>
10
+ <% if (data = step_structured(step)) %>
11
+ <pre class="args"><%= pretty_args(data) %></pre>
4
12
  <% end %>
5
13
  <div id="silas-step-<%= step.id %>-tools">
6
14
  <%= render partial: "silas/inbox/invocations/invocation", collection: step.tool_invocations, as: :invocation %>
@@ -1,7 +1,25 @@
1
1
  <div class="turn-head">
2
2
  <span class="turn-input"><%= truncate(turn.input, length: 90) %></span>
3
3
  <%= status_pill(turn.status) %>
4
+ <% if turn.active? && !turn.cancel_requested_at %>
5
+ <%= form_with url: cancel_inbox_turn_path(turn), method: :post, class: "inline" do %>
6
+ <button class="btn-cancel" title="Honored at the next step boundary">Cancel</button>
7
+ <% end %>
8
+ <% elsif turn.cancel_requested_at && turn.active? %>
9
+ <span class="muted">cancel requested…</span>
10
+ <% end %>
4
11
  </div>
5
- <% if turn.failure_reason.present? %>
12
+ <% if turn.budget_parked? %>
13
+ <div class="approval">
14
+ <h3>Budget reached — <%= turn.failure_reason %></h3>
15
+ <div class="muted">Parked at zero compute. Raise the limit and the turn resumes
16
+ by replaying completed work from rows — no re-calls, no re-effects.</div>
17
+ <%= form_with url: raise_budget_inbox_turn_path(turn), method: :post, class: "topup-form" do %>
18
+ <input name="value" type="number" step="any" min="0" required class="topup-input"
19
+ placeholder="<%= turn.failure_reason == "max_cost" ? "new limit in dollars, e.g. 2.50" : "new limit, e.g. 200000" %>">
20
+ <button class="btn approve">Raise &amp; resume</button>
21
+ <% end %>
22
+ </div>
23
+ <% elsif turn.failure_reason.present? %>
6
24
  <div class="muted">failed: <%= turn.failure_reason %></div>
7
25
  <% end %>
data/config/routes.rb CHANGED
@@ -1,4 +1,23 @@
1
1
  Silas::Engine.routes.draw do
2
+ namespace :api do
3
+ namespace :v1 do
4
+ resources :sessions, only: %i[create show] do
5
+ resources :turns, only: :create
6
+ resources :approvals, only: :index
7
+ get :stream, on: :member, to: "streams#show"
8
+ end
9
+ resources :turns, only: [] do
10
+ member { post :cancel }
11
+ end
12
+ resources :approvals, only: [] do
13
+ member do
14
+ post :approve
15
+ post :decline
16
+ end
17
+ end
18
+ end
19
+ end
20
+
2
21
  namespace :channels do
3
22
  post "slack/events", to: "slack#events"
4
23
  post "slack/actions", to: "slack#actions"
@@ -8,7 +27,15 @@ Silas::Engine.routes.draw do
8
27
 
9
28
  namespace :inbox do
10
29
  root to: "sessions#index"
11
- resources :sessions, only: %i[index show]
30
+ resources :sessions, only: %i[index show create] do
31
+ resources :turns, only: :create
32
+ end
33
+ resources :turns, only: [] do
34
+ member do
35
+ post :cancel
36
+ post :raise_budget
37
+ end
38
+ end
12
39
  resources :invocations, only: [] do
13
40
  member do
14
41
  post :approve
@@ -0,0 +1,9 @@
1
+ class DropAgentSdkColumnsFromSilasTurns < ActiveRecord::Migration[8.1]
2
+ # The :agent_sdk engine was removed in 0.2. cli_session_id was its fail-closed
3
+ # resume marker; mcp_token was written by Mcp::Server but read by nothing (the
4
+ # token is minted and compared in memory).
5
+ def change
6
+ remove_column :silas_turns, :cli_session_id, :string
7
+ remove_column :silas_turns, :mcp_token, :string
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ class AddProviderToSilasSteps < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # The provider RubyLLM's resolution picked for the step's model, stamped at
4
+ # persist time — cost lookups price against (model, provider) forever
5
+ # after, immune to registry tie-break changes (85/1081 registry ids exist
6
+ # under multiple providers at different prices).
7
+ add_column :silas_steps, :provider, :string
8
+
9
+ # Declared in 0.1.0, defaulted to 0, never written — a silent lie to
10
+ # anyone who queried it. Cost is derived at read time from step tokens.
11
+ remove_column :silas_turns, :cost_microcents, :integer, null: false, default: 0
12
+ end
13
+ end