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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +153 -0
- data/README.md +81 -14
- data/app/controllers/concerns/silas/api/serialization.rb +63 -0
- data/app/controllers/silas/api/base_controller.rb +24 -0
- data/app/controllers/silas/api/v1/approvals_controller.rb +35 -0
- data/app/controllers/silas/api/v1/sessions_controller.rb +39 -0
- data/app/controllers/silas/api/v1/streams_controller.rb +117 -0
- data/app/controllers/silas/api/v1/turns_controller.rb +32 -0
- data/app/controllers/silas/inbox/sessions_controller.rb +39 -2
- data/app/controllers/silas/inbox/turns_controller.rb +51 -0
- data/app/helpers/silas/inbox/trace_helper.rb +5 -0
- data/app/jobs/silas/agent_loop_job.rb +46 -43
- data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
- data/app/models/silas/session.rb +6 -0
- data/app/models/silas/tool_invocation.rb +13 -1
- data/app/models/silas/turn.rb +10 -0
- data/app/views/layouts/silas/inbox.html.erb +20 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
- data/app/views/silas/inbox/sessions/index.html.erb +31 -5
- data/app/views/silas/inbox/sessions/show.html.erb +11 -0
- data/app/views/silas/inbox/steps/_step.html.erb +9 -1
- data/app/views/silas/inbox/turns/_header.html.erb +19 -1
- data/config/routes.rb +28 -1
- data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
- data/db/migrate/20260725000001_add_provider_to_silas_steps.rb +13 -0
- data/lib/generators/silas/install/install_generator.rb +34 -14
- data/lib/generators/silas/install/templates/agent.yml +10 -1
- data/lib/generators/silas/install/templates/bin_ci +2 -2
- data/lib/generators/silas/install/templates/initializer.rb +30 -5
- data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
- data/lib/silas/agent.rb +4 -0
- data/lib/silas/chat.rb +47 -13
- data/lib/silas/configuration.rb +77 -37
- data/lib/silas/delta_buffer.rb +50 -0
- data/lib/silas/doctor.rb +155 -0
- data/lib/silas/engine.rb +6 -0
- data/lib/silas/engines/base.rb +6 -8
- data/lib/silas/engines/ruby_llm.rb +28 -6
- data/lib/silas/errors.rb +3 -3
- data/lib/silas/eval/assertions.rb +18 -0
- data/lib/silas/eval/scripted_engine.rb +0 -2
- data/lib/silas/eval/transcript.rb +1 -0
- data/lib/silas/inbox/cost.rb +37 -13
- data/lib/silas/inbox/delta_broadcaster.rb +38 -0
- data/lib/silas/ledger.rb +26 -10
- data/lib/silas/mcp/handler.rb +6 -5
- data/lib/silas/mcp/server.rb +9 -9
- data/lib/silas/message_builder.rb +9 -2
- data/lib/silas/named_agent.rb +1 -0
- data/lib/silas/registry.rb +18 -8
- data/lib/silas/step_runner.rb +33 -6
- data/lib/silas/tool.rb +5 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +10 -9
- data/lib/tasks/silas_doctor.rake +17 -0
- metadata +14 -6
- data/lib/silas/agent_sdk/cli.rb +0 -59
- data/lib/silas/agent_sdk/stream_parser.rb +0 -86
- data/lib/silas/agent_sdk/version_guard.rb +0 -26
- data/lib/silas/engines/agent_sdk.rb +0 -75
- 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
|
-
|
|
6
|
-
|
|
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) {
|
|
70
|
+
Silas.with_agent_scope(scope) { run_turn(turn) }
|
|
30
71
|
else
|
|
31
|
-
|
|
72
|
+
run_turn(turn)
|
|
32
73
|
end
|
|
33
74
|
end
|
|
34
75
|
|
|
35
76
|
private
|
|
36
77
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
data/app/models/silas/session.rb
CHANGED
|
@@ -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? #
|
|
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")
|
data/app/models/silas/turn.rb
CHANGED
|
@@ -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
|
-
<%
|
|
9
|
-
|
|
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?
|
|
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
|
-
|
|
23
|
-
<%
|
|
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.
|
|
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.
|
|
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 & 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
|