silas 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/app/controllers/silas/channels/approvals_controller.rb +34 -0
- data/app/controllers/silas/channels/base_controller.rb +22 -0
- data/app/controllers/silas/channels/slack_controller.rb +58 -0
- data/app/controllers/silas/inbox/base_controller.rb +37 -0
- data/app/controllers/silas/inbox/invocations_controller.rb +44 -0
- data/app/controllers/silas/inbox/sessions_controller.rb +16 -0
- data/app/helpers/silas/inbox/trace_helper.rb +42 -0
- data/app/jobs/silas/agent_loop_job.rb +95 -0
- data/app/jobs/silas/channel_delivery_job.rb +62 -0
- data/app/jobs/silas/dead_job_rescuer_job.rb +31 -0
- data/app/jobs/silas/schedule_job.rb +22 -0
- data/app/mailboxes/silas/agent_mailbox.rb +35 -0
- data/app/mailers/silas/channel_mailer.rb +17 -0
- data/app/models/concerns/silas/inbox/broadcastable.rb +86 -0
- data/app/models/silas/application_record.rb +5 -0
- data/app/models/silas/session.rb +36 -0
- data/app/models/silas/step.rb +33 -0
- data/app/models/silas/tool_invocation.rb +78 -0
- data/app/models/silas/turn.rb +58 -0
- data/app/views/layouts/silas/inbox.html.erb +91 -0
- data/app/views/silas/channel_mailer/approval.text.erb +9 -0
- data/app/views/silas/channels/approvals/done.html.erb +2 -0
- data/app/views/silas/channels/approvals/error.html.erb +2 -0
- data/app/views/silas/channels/approvals/show.html.erb +9 -0
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +11 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +11 -0
- data/app/views/silas/inbox/sessions/_cost.html.erb +1 -0
- data/app/views/silas/inbox/sessions/index.html.erb +23 -0
- data/app/views/silas/inbox/sessions/show.html.erb +20 -0
- data/app/views/silas/inbox/steps/_step.html.erb +8 -0
- data/app/views/silas/inbox/turns/_header.html.erb +7 -0
- data/app/views/silas/inbox/turns/_turn.html.erb +8 -0
- data/config/routes.rb +19 -0
- data/db/migrate/20260714000001_create_silas_tables.rb +78 -0
- data/db/migrate/20260715000001_add_channel_outbound_markers.rb +8 -0
- data/db/migrate/20260715000002_add_agent_sdk_columns_to_turns.rb +9 -0
- data/db/migrate/20260715000003_add_parent_session_to_silas_sessions.rb +8 -0
- data/lib/generators/silas/install/install_generator.rb +81 -0
- data/lib/generators/silas/install/templates/agent.yml +9 -0
- data/lib/generators/silas/install/templates/bin_ci +7 -0
- data/lib/generators/silas/install/templates/channel_email.rb +18 -0
- data/lib/generators/silas/install/templates/channel_slack.rb +19 -0
- data/lib/generators/silas/install/templates/example_eval.rb +22 -0
- data/lib/generators/silas/install/templates/example_schedule.md +11 -0
- data/lib/generators/silas/install/templates/example_skill.md +8 -0
- data/lib/generators/silas/install/templates/example_tool.rb +12 -0
- data/lib/generators/silas/install/templates/initializer.rb +16 -0
- data/lib/generators/silas/install/templates/instructions.md +4 -0
- data/lib/silas/agent.rb +33 -0
- data/lib/silas/agent_scope.rb +6 -0
- data/lib/silas/agent_sdk/cli.rb +59 -0
- data/lib/silas/agent_sdk/stream_parser.rb +86 -0
- data/lib/silas/agent_sdk/version_guard.rb +26 -0
- data/lib/silas/budget.rb +42 -0
- data/lib/silas/channel.rb +69 -0
- data/lib/silas/configuration.rb +164 -0
- data/lib/silas/connection.rb +77 -0
- data/lib/silas/connections.rb +52 -0
- data/lib/silas/engine.rb +36 -0
- data/lib/silas/engines/agent_sdk.rb +75 -0
- data/lib/silas/engines/base.rb +30 -0
- data/lib/silas/engines/ruby_llm.rb +136 -0
- data/lib/silas/errors.rb +26 -0
- data/lib/silas/eval/assertions.rb +107 -0
- data/lib/silas/eval/driver.rb +47 -0
- data/lib/silas/eval/dsl.rb +55 -0
- data/lib/silas/eval/grader.rb +37 -0
- data/lib/silas/eval/result.rb +28 -0
- data/lib/silas/eval/runner.rb +38 -0
- data/lib/silas/eval/scripted_engine.rb +39 -0
- data/lib/silas/eval/transcript.rb +22 -0
- data/lib/silas/eval.rb +35 -0
- data/lib/silas/inbox/cost.rb +58 -0
- data/lib/silas/inbox.rb +23 -0
- data/lib/silas/instructions.rb +55 -0
- data/lib/silas/ledger.rb +178 -0
- data/lib/silas/mcp/client.rb +86 -0
- data/lib/silas/mcp/handler.rb +89 -0
- data/lib/silas/mcp/server.rb +134 -0
- data/lib/silas/message_builder.rb +63 -0
- data/lib/silas/nested_runner.rb +41 -0
- data/lib/silas/registry.rb +155 -0
- data/lib/silas/sandbox/docker.rb +67 -0
- data/lib/silas/sandbox/null.rb +17 -0
- data/lib/silas/sandbox.rb +15 -0
- data/lib/silas/schedule/compiler.rb +52 -0
- data/lib/silas/schedule.rb +109 -0
- data/lib/silas/skill.rb +21 -0
- data/lib/silas/slack.rb +55 -0
- data/lib/silas/step_runner.rb +98 -0
- data/lib/silas/subprocess_runner.rb +41 -0
- data/lib/silas/tool.rb +93 -0
- data/lib/silas/tools/delegate.rb +42 -0
- data/lib/silas/tools/load_skill.rb +25 -0
- data/lib/silas/tools/run_code.rb +21 -0
- data/lib/silas/version.rb +3 -0
- data/lib/silas.rb +144 -0
- data/lib/tasks/silas_eval.rake +20 -0
- data/lib/tasks/silas_schedules.rake +33 -0
- metadata +232 -0
|
@@ -0,0 +1,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
|
data/lib/silas/inbox.rb
ADDED
|
@@ -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
|