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,107 @@
1
+ module Silas
2
+ module Eval
3
+ module Assertions
4
+ # Assertions read ONLY the transcript rows; the block runs in this context.
5
+ # They collect failures rather than raising, so one eval reports all misses.
6
+ class Context
7
+ EXECUTED = %w[completed started in_doubt].freeze
8
+ MONEY = /(?<cur>[£$€])\s?(?<amt>\d{1,3}(?:,\d{3})*(?:\.\d+)?|\d+(?:\.\d+)?)/
9
+
10
+ attr_reader :failures, :skips
11
+
12
+ def initialize(transcript)
13
+ @t = transcript
14
+ @failures = []
15
+ @skips = []
16
+ end
17
+
18
+ def assert_tool_called(name, times: nil)
19
+ n = execed(name).size
20
+ check(times ? n == times : n.positive?,
21
+ "expected #{name} called#{" #{times}x" if times}, saw #{n} (#{summary})")
22
+ end
23
+
24
+ def assert_no_tool_called(name = nil)
25
+ bad = name ? execed(name) : @t.invocations.select { |i| EXECUTED.include?(i.status) }
26
+ check(bad.empty?, "expected no#{" #{name}" if name} tool execution, saw #{bad.map(&:tool_name)}")
27
+ end
28
+
29
+ def assert_tool_arg(name, key, value = :__unset, &pred)
30
+ inv = execed(name).last || @t.invocations_for(name).last
31
+ return check(false, "#{name} was never called") unless inv
32
+
33
+ got = inv.arguments[key.to_s]
34
+ ok = pred ? pred.call(got) : got == value
35
+ check(ok, "#{name}.#{key} expected #{pred ? 'predicate' : value.inspect}, got #{got.inspect}")
36
+ end
37
+
38
+ def assert_final_matches(matcher)
39
+ check(matcher === @t.final_text, "final answer #{@t.final_text.inspect} does not match #{matcher.inspect}")
40
+ end
41
+
42
+ # No-hallucinated-price guard: every money amount in the final answer must
43
+ # trace to a number the agent actually saw (tool results or the user input),
44
+ # allowing pence<->pounds scaling.
45
+ def assert_no_hallucinated_price(allowed: [], scale: [ 1, 100, 0.01 ])
46
+ stated = @t.final_text.scan(MONEY).map { |_c, a| a.delete(",").to_f }
47
+ grounded = grounded_numbers + Array(allowed).map(&:to_f)
48
+ bad = stated.reject { |n| grounded.any? { |g| scale.any? { |s| (g * s - n).abs < 0.005 } } }
49
+ check(bad.empty?, "final answer states ungrounded amount(s) #{bad.inspect}; grounded=#{grounded.sort.inspect}")
50
+ end
51
+
52
+ def assert_approved(tool: nil)
53
+ inv = tool ? @t.invocations_for(tool).last : @t.invocations.last
54
+ check(inv&.approval_state == "approved", "expected #{tool || 'an invocation'} approved, state=#{inv&.approval_state.inspect}")
55
+ end
56
+
57
+ def assert_parked(tool: nil)
58
+ parked = @t.parked?
59
+ parked &&= @t.invocations_for(tool).any? { |i| i.approval_state == "required" || i.status == "in_doubt" } if tool
60
+ check(parked, "expected turn parked#{" on #{tool}" if tool}, status=#{@t.status}")
61
+ end
62
+
63
+ def assert_turn_completed
64
+ check(@t.completed?, "turn not completed (#{@t.status}/#{@t.turn.failure_reason})")
65
+ end
66
+
67
+ def assert_turn_failed(reason: nil)
68
+ ok = @t.status == "failed" && (reason.nil? || @t.turn.failure_reason == reason)
69
+ check(ok, "expected failed#{"/#{reason}" if reason}, got #{@t.status}/#{@t.turn.failure_reason}")
70
+ end
71
+
72
+ # LLM-graded — opt-in; SKIPS offline (never fails the gate unless SILAS_EVAL_STRICT=1).
73
+ def assert_rubric(criteria)
74
+ unless Grader.available?
75
+ @skips << "assert_rubric skipped (no grader / offline)"
76
+ return check(false, "assert_rubric strict skip (SILAS_EVAL_STRICT)") if ENV["SILAS_EVAL_STRICT"] == "1"
77
+
78
+ return
79
+ end
80
+ verdict = Grader.grade(Grader.prompt(@t, criteria))
81
+ check(verdict.start_with?("PASS"), "rubric FAIL: #{verdict}")
82
+ end
83
+
84
+ private
85
+
86
+ def execed(name) = @t.invocations_for(name).select { |i| EXECUTED.include?(i.status) }
87
+ def summary = @t.invocations.map { |i| "#{i.tool_name}:#{i.status}" }.join(",")
88
+ def check(cond, msg) = (@failures << msg unless cond)
89
+
90
+ def grounded_numbers
91
+ nums = []
92
+ walk = lambda do |v|
93
+ case v
94
+ when Numeric then nums << v.to_f
95
+ when Hash then v.each_value(&walk)
96
+ when Array then v.each(&walk)
97
+ when String then v.scan(/\d+(?:\.\d+)?/) { |d| nums << d.to_f }
98
+ end
99
+ end
100
+ @t.results.each(&walk)
101
+ walk.call(@t.input)
102
+ nums
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,47 @@
1
+ module Silas
2
+ module Eval
3
+ # Drives ONE scenario to its resting state through the REAL framework loop
4
+ # (Silas.agent.start + AgentLoopJob.perform_now inline), then hands back a
5
+ # Transcript. In fake mode the ScriptedEngine supplies the model's decisions;
6
+ # the real Ledger runs the real tools.
7
+ module Driver
8
+ module_function
9
+
10
+ def drive(scenario, root: Rails.root)
11
+ prior_adapter = ActiveJob::Base.queue_adapter
12
+ ActiveJob::Base.queue_adapter = :test
13
+ configure!(scenario, root)
14
+
15
+ session = Silas.agent.start(input: scenario.input, metadata: scenario.metadata)
16
+ turn = session.turns.order(:index).last
17
+ Silas::AgentLoopJob.perform_now(turn.id)
18
+
19
+ scenario.approvals.each do |tool|
20
+ inv = turn.reload.tool_invocations.find { |i| i.tool_name == tool && i.approval_state == "required" }
21
+ next unless inv
22
+
23
+ inv.approve!(by: "eval")
24
+ Silas::AgentLoopJob.perform_now(turn.id)
25
+ end
26
+
27
+ Transcript.new(turn.reload, session.reload)
28
+ ensure
29
+ ActiveJob::Base.queue_adapter = prior_adapter
30
+ end
31
+
32
+ def configure!(scenario, root)
33
+ Silas::Registry.install!(root: root)
34
+ engine = scenario.real? ? nil : ScriptedEngine.new(scenario.steps)
35
+ base_resolver = Silas.config.tool_resolver
36
+ Silas.configure do |c|
37
+ c.engine = engine if engine
38
+ c.isolate_steps = false
39
+ c.max_steps = scenario.max_steps if scenario.max_steps
40
+ if scenario.stubs.any?
41
+ c.tool_resolver = ->(name) { scenario.stubs[name] || base_resolver.call(name) }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,55 @@
1
+ module Silas
2
+ module Eval
3
+ # Define-time recorder for the scenario DSL.
4
+ class DSL
5
+ def initialize(name, tags)
6
+ @name = name
7
+ @tags = tags
8
+ @mode = :fake
9
+ @steps = {}
10
+ @stubs = {}
11
+ @approvals = []
12
+ @metadata = {}
13
+ @max_steps = nil
14
+ end
15
+
16
+ def input(text) = (@input = text)
17
+ def metadata(h) = (@metadata = h)
18
+ def mode(m) = (@mode = m)
19
+ def max_steps(n) = (@max_steps = n)
20
+ def approve(tool:) = (@approvals << tool.to_s)
21
+
22
+ # on_step(0, text:, call: {name:, arguments:}, calls: [ {…}, … ])
23
+ def on_step(index, text: nil, call: nil, calls: [])
24
+ tcs = (calls + [ call ].compact).each_with_index.map do |c, n|
25
+ Silas::Engines::ToolCall.new(id: "eval_s#{index}_#{n}", name: c[:name].to_s,
26
+ arguments: (c[:arguments] || {}).stringify_keys)
27
+ end
28
+ blocks = []
29
+ blocks << { "type" => "text", "text" => text } if text
30
+ @steps[index] = { blocks: blocks, tool_calls: tcs }
31
+ end
32
+
33
+ # Override a real tool with a stub for a side-effect-free eval.
34
+ def stub_tool(name, effect_mode: :at_most_once, approval: :never, &body)
35
+ obj = Object.new
36
+ obj.define_singleton_method(:effect_mode) { effect_mode }
37
+ obj.define_singleton_method(:approval_policy) { approval }
38
+ obj.singleton_class.attr_accessor :session
39
+ obj.define_singleton_method(:call, &body)
40
+ @stubs[name.to_s] = obj
41
+ end
42
+
43
+ def expect(&block) = (@assertions = block)
44
+
45
+ def to_scenario
46
+ raise Silas::Error, "eval #{@name.inspect} needs an input" unless @input
47
+ raise Silas::Error, "eval #{@name.inspect} needs an expect block" unless @assertions
48
+
49
+ Scenario.new(name: @name, input: @input, mode: @mode, steps: @steps,
50
+ stubs: @stubs, approvals: @approvals, metadata: @metadata,
51
+ max_steps: @max_steps, tags: @tags, assertions: @assertions)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,37 @@
1
+ module Silas
2
+ module Eval
3
+ # Opt-in LLM judge for assert_rubric. Offline-skippable so the deterministic
4
+ # gate never depends on a live model unless you ask for it.
5
+ module Grader
6
+ module_function
7
+
8
+ def available? = ENV["SILAS_EVAL_OFFLINE"] != "1" && !grader.nil?
9
+ def grade(prompt) = grader.call(prompt).to_s.strip
10
+
11
+ def grader
12
+ Silas.config.eval_grader || builtin
13
+ end
14
+
15
+ def builtin
16
+ return nil if ENV["ANTHROPIC_API_KEY"].blank?
17
+
18
+ lambda do |prompt|
19
+ ::RubyLLM.chat(model: ENV["SILAS_EVAL_GRADER_MODEL"] || "claude-haiku-4-5-20251001")
20
+ .ask(prompt).content.to_s
21
+ end
22
+ end
23
+
24
+ def prompt(transcript, criteria)
25
+ calls = transcript.invocations.map { |i| "#{i.tool_name}(#{i.arguments}) => #{i.result}" }.join("; ")
26
+ <<~PROMPT
27
+ You are grading an AI agent transcript. Reply with exactly one line: "PASS" or "FAIL: <reason>".
28
+ Criteria: #{criteria}
29
+
30
+ User: #{transcript.input}
31
+ Tool calls: #{calls}
32
+ Final answer: #{transcript.final_text}
33
+ PROMPT
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,28 @@
1
+ module Silas
2
+ module Eval
3
+ class Result
4
+ attr_reader :scenario, :failures, :skips
5
+
6
+ def initialize(scenario, failures = [], skips = [], skipped: false)
7
+ @scenario = scenario
8
+ @failures = failures
9
+ @skips = skips
10
+ @skipped = skipped
11
+ end
12
+
13
+ def self.skipped(scenario, why) = new(scenario, [], [ why ], skipped: true)
14
+ def self.errored(scenario, error) = new(scenario, [ "ERROR: #{error.class}: #{error.message}" ])
15
+
16
+ def pass? = @failures.empty? && !@skipped
17
+ def skipped? = @skipped
18
+
19
+ def line
20
+ mark = skipped? ? "SKIP" : (pass? ? "PASS" : "FAIL")
21
+ out = " [#{mark}] #{@scenario.name}"
22
+ out += "\n - #{@failures.join("\n - ")}" unless @failures.empty?
23
+ out += "\n (#{@skips.join('; ')})" unless @skips.empty?
24
+ out
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,38 @@
1
+ module Silas
2
+ module Eval
3
+ # Discovers *_eval.rb files, runs each scenario in isolation against the real
4
+ # loop, reports, and returns a pass/fail boolean (the rake task exits 1 on false).
5
+ module Runner
6
+ module_function
7
+
8
+ def run(dir:, pattern: nil, mode: nil, root: Rails.root)
9
+ Eval.reset!
10
+ Dir[File.join(dir, "**/*_eval.rb")].sort.each { |f| load f }
11
+ picked = Eval.scenarios.select { |s| pattern.nil? || s.name.match?(pattern) }
12
+
13
+ results = picked.map { |s| run_one(s.with_mode(mode), root) }
14
+ report(results)
15
+ results.all? { |r| r.pass? || r.skipped? }
16
+ end
17
+
18
+ def run_one(scenario, root = Rails.root)
19
+ if scenario.real? && ENV["ANTHROPIC_API_KEY"].to_s.empty?
20
+ return Result.skipped(scenario, "real mode, no ANTHROPIC_API_KEY")
21
+ end
22
+
23
+ transcript = Driver.drive(scenario, root: root)
24
+ ctx = Assertions::Context.new(transcript)
25
+ ctx.instance_exec(&scenario.assertions)
26
+ Result.new(scenario, ctx.failures, ctx.skips)
27
+ rescue StandardError => e
28
+ Result.errored(scenario, e)
29
+ end
30
+
31
+ def report(results)
32
+ results.each { |r| puts r.line }
33
+ failing = results.count { |r| !(r.pass? || r.skipped?) }
34
+ puts "\nsilas eval: #{results.size} scenarios, #{failing} failing, #{results.count(&:skipped?)} skipped"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,39 @@
1
+ module Silas
2
+ module Eval
3
+ # The productized FakeEngine: a pure function of context[:index] that lets an
4
+ # eval script the MODEL's decisions while the REAL Ledger runs the REAL tools —
5
+ # so assertions see a genuine transcript.
6
+ class ScriptedEngine < Silas::Engines::Base
7
+ def self.loop_ownership = :framework
8
+
9
+ attr_reader :calls
10
+
11
+ def initialize(steps)
12
+ @steps = steps
13
+ @calls = []
14
+ end
15
+
16
+ def execute_step(context, &_on_event)
17
+ i = context[:index]
18
+ @calls << { index: i }
19
+ spec = @steps[i]
20
+ return terminal("OK.") unless spec
21
+
22
+ Silas::Engines::Result.new(
23
+ blocks: spec[:blocks],
24
+ tool_calls: spec[:tool_calls],
25
+ stop_reason: spec[:tool_calls].empty? ? "end_turn" : "tool_use",
26
+ usage: { input_tokens: 10, output_tokens: 5 }
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ def terminal(text)
33
+ Silas::Engines::Result.new(blocks: [ { "type" => "text", "text" => text } ],
34
+ tool_calls: [], stop_reason: "end_turn",
35
+ usage: { input_tokens: 1, output_tokens: 1 })
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,22 @@
1
+ module Silas
2
+ module Eval
3
+ # Read-only view over a turn at its resting state — the surface assertions read.
4
+ class Transcript
5
+ attr_reader :turn, :session
6
+
7
+ def initialize(turn, session)
8
+ @turn = turn
9
+ @session = session
10
+ end
11
+
12
+ def input = @turn.input
13
+ def status = @turn.status
14
+ def completed? = @turn.completed?
15
+ def parked? = @turn.parked?
16
+ def final_text = @turn.answer_text.to_s
17
+ def invocations = @turn.tool_invocations.order(:id).to_a
18
+ def invocations_for(name) = invocations.select { |i| i.tool_name == name.to_s }
19
+ def results = invocations.map(&:result).compact
20
+ end
21
+ end
22
+ end
data/lib/silas/eval.rb ADDED
@@ -0,0 +1,35 @@
1
+ require "silas/eval/scripted_engine"
2
+ require "silas/eval/dsl"
3
+ require "silas/eval/transcript"
4
+ require "silas/eval/assertions"
5
+ require "silas/eval/grader"
6
+ require "silas/eval/driver"
7
+ require "silas/eval/result"
8
+ require "silas/eval/runner"
9
+
10
+ module Silas
11
+ # Evals: fixtures are scenarios, assertions run against the DURABLE transcript
12
+ # (the real Session/Turn/Step/ToolInvocation rows). The harness only observes
13
+ # the exactly-once loop; it never touches it. Written like Rails tests, run as
14
+ # a deploy gate (non-zero exit on failure). The launch differentiator.
15
+ module Eval
16
+ class AssertionError < Silas::Error; end
17
+
18
+ Scenario = Struct.new(:name, :input, :mode, :steps, :stubs, :approvals,
19
+ :metadata, :max_steps, :tags, :assertions, keyword_init: true) do
20
+ def real? = mode == :real
21
+ def with_mode(m) = m ? dup.tap { |s| s.mode = m } : self
22
+ end
23
+
24
+ class << self
25
+ def scenarios = @scenarios ||= []
26
+ def reset! = (@scenarios = [])
27
+
28
+ def scenario(name, tags: [], &block)
29
+ dsl = DSL.new(name, tags)
30
+ dsl.instance_exec(&block)
31
+ scenarios << dsl.to_scenario
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,58 @@
1
+ module Silas
2
+ module Inbox
3
+ # cost_microcents is declared on silas_turns but never populated (only
4
+ # silas_steps carry real token counts). So the inbox derives cost at read
5
+ # time from step tokens x a host-supplied price map — and honestly reports
6
+ # `unpriced` when a model isn't in the map rather than a lying £0.00.
7
+ module Cost
8
+ module_function
9
+
10
+ def for_session(session)
11
+ rows = Silas::Step.joins(:turn)
12
+ .where(silas_turns: { session_id: session.id })
13
+ .group(:model)
14
+ .pluck(:model, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
15
+ aggregate(rows)
16
+ end
17
+
18
+ def for_turn(turn)
19
+ rows = Silas::Step.where(turn_id: turn.id)
20
+ .group(:model)
21
+ .pluck(:model, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
22
+ aggregate(rows)
23
+ end
24
+
25
+ def for_agent(agent_name)
26
+ rows = Silas::Step.joins(turn: :session)
27
+ .where(silas_sessions: { agent_name: agent_name })
28
+ .group(:model)
29
+ .pluck(:model, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
30
+ aggregate(rows)
31
+ end
32
+
33
+ def aggregate(rows)
34
+ input = output = microcents = 0
35
+ unpriced = false
36
+ rows.each do |model, in_tok, out_tok|
37
+ in_tok = in_tok.to_i
38
+ out_tok = out_tok.to_i
39
+ input += in_tok
40
+ output += out_tok
41
+ if (price = Silas.config.model_prices[model])
42
+ microcents += (in_tok * price[:in] + out_tok * price[:out]) / 1000
43
+ else
44
+ unpriced = true
45
+ end
46
+ end
47
+ { input_tokens: input, output_tokens: output, microcents: microcents, unpriced: unpriced }
48
+ end
49
+
50
+ # microcents -> "$0.0123" (or nil when unpriced with no priced tokens)
51
+ def format(cents)
52
+ return nil if cents.nil?
53
+
54
+ "$%.4f" % (cents / 1_000_000.0)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,23 @@
1
+ module Silas
2
+ # The mountable inbox UI (at /silas/inbox). It renders the existing rows
3
+ # (Session -> Turn -> Step -> ToolInvocation) as a live trace — no events
4
+ # table — and streams changes over Turbo when the host has turbo-rails.
5
+ module Inbox
6
+ module_function
7
+
8
+ # turbo-rails is NOT a gem dependency (the core stays lean, like the
9
+ # solid_queue guard). Broadcasting activates only when the host bundles it.
10
+ def streaming_available?
11
+ defined?(Turbo::StreamsChannel)
12
+ end
13
+
14
+ # Broadcasting is on when Turbo is present. A host can hard-disable it.
15
+ def streaming?
16
+ streaming_available? && Silas.config.inbox_streaming != false
17
+ end
18
+
19
+ def stream_name(session_id)
20
+ "silas:inbox:session:#{session_id}"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,55 @@
1
+ require "erb"
2
+
3
+ module Silas
4
+ # Renders instructions.md ONCE per turn into an immutable snapshot — the
5
+ # determinism anchor. (Skill-index injection and loaded-skill inlining land
6
+ # with the skills work; the snapshot-once mechanics are load-bearing now.)
7
+ module Instructions
8
+ module_function
9
+
10
+ def snapshot!(turn)
11
+ return if turn.instructions_snapshot.present? # idempotent: replay-safe
12
+
13
+ turn.update!(
14
+ instructions_snapshot: render(turn),
15
+ definitions_digest: Silas.config.definitions_digest&.call.to_s
16
+ )
17
+ end
18
+
19
+ def render(turn)
20
+ [ base_instructions(turn), skill_index_block(turn.session), loaded_skills_block(turn.session) ]
21
+ .compact.join("\n\n")
22
+ end
23
+
24
+ def base_instructions(turn)
25
+ # config.instructions_dir points at the active agent's directory (root or a
26
+ # subagent, swapped during a nested run).
27
+ dir = Silas.config.instructions_dir || Rails.root.join("app/agent")
28
+ path = Pathname(dir).join("instructions.md")
29
+ return default_instructions unless path.exist?
30
+
31
+ ERB.new(path.read).result_with_hash(session: turn.session, agent_name: turn.session.agent_name)
32
+ end
33
+
34
+ # Advertise skill descriptions (eve's routing hint); bodies load on demand.
35
+ def skill_index_block(session)
36
+ advertised = Silas.skills.reject { |s| session.loaded_skills.include?(s.name) }
37
+ return nil if advertised.empty?
38
+
39
+ lines = advertised.map { |s| "- #{s.name}: #{s.description}" }
40
+ "## Available skills\n\nLoad with the load_skill tool when relevant:\n#{lines.join("\n")}"
41
+ end
42
+
43
+ # Skills loaded in prior turns are inlined "for the rest of the session".
44
+ def loaded_skills_block(session)
45
+ loaded = Silas.skills.select { |s| session.loaded_skills.include?(s.name) }
46
+ return nil if loaded.empty?
47
+
48
+ loaded.map { |s| "## Skill: #{s.name}\n\n#{s.body.strip}" }.join("\n\n")
49
+ end
50
+
51
+ def default_instructions
52
+ "You are a helpful agent."
53
+ end
54
+ end
55
+ end