silas 0.2.0 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -1
  3. data/README.md +44 -0
  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 +16 -3
  11. data/app/controllers/silas/inbox/turns_controller.rb +19 -0
  12. data/app/helpers/silas/inbox/trace_helper.rb +5 -0
  13. data/app/models/silas/session.rb +6 -0
  14. data/app/models/silas/turn.rb +9 -0
  15. data/app/views/layouts/silas/inbox.html.erb +6 -0
  16. data/app/views/silas/inbox/sessions/index.html.erb +11 -3
  17. data/app/views/silas/inbox/steps/_step.html.erb +3 -0
  18. data/app/views/silas/inbox/turns/_header.html.erb +12 -1
  19. data/config/routes.rb +23 -1
  20. data/db/migrate/20260725000001_add_provider_to_silas_steps.rb +13 -0
  21. data/lib/generators/silas/install/install_generator.rb +1 -0
  22. data/lib/generators/silas/install/templates/agent.yml +10 -1
  23. data/lib/generators/silas/install/templates/initializer.rb +5 -4
  24. data/lib/silas/agent.rb +4 -0
  25. data/lib/silas/chat.rb +2 -0
  26. data/lib/silas/configuration.rb +30 -34
  27. data/lib/silas/doctor.rb +155 -0
  28. data/lib/silas/engines/ruby_llm.rb +13 -2
  29. data/lib/silas/eval/assertions.rb +18 -0
  30. data/lib/silas/eval/transcript.rb +1 -0
  31. data/lib/silas/inbox/cost.rb +37 -13
  32. data/lib/silas/message_builder.rb +9 -2
  33. data/lib/silas/named_agent.rb +1 -0
  34. data/lib/silas/registry.rb +18 -8
  35. data/lib/silas/step_runner.rb +12 -0
  36. data/lib/silas/version.rb +1 -1
  37. data/lib/silas.rb +1 -0
  38. data/lib/tasks/silas_doctor.rake +17 -0
  39. metadata +10 -1
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"
@@ -12,7 +31,10 @@ Silas::Engine.routes.draw do
12
31
  resources :turns, only: :create
13
32
  end
14
33
  resources :turns, only: [] do
15
- member { post :cancel }
34
+ member do
35
+ post :cancel
36
+ post :raise_budget
37
+ end
16
38
  end
17
39
  resources :invocations, only: [] do
18
40
  member do
@@ -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
@@ -102,6 +102,7 @@ module Silas
102
102
  Silas installed. Next:
103
103
  1. bin/rails db:migrate
104
104
  2. export ANTHROPIC_API_KEY=sk-ant-... (config/initializers/ruby_llm.rb reads it)
105
+ then `bin/rails silas:doctor` to verify the whole setup
105
106
  3. Edit app/agent/instructions.md (your agent's persona)
106
107
  4. Write tools in app/agent/tools/ (keyword signature = schema)
107
108
  5. Talk to it: bin/rails silas:chat (or Silas.agent.start(input: "hello"))
@@ -1,5 +1,5 @@
1
1
  # Data-only agent config. Model defaults to Silas.config.default_model.
2
- # model: claude-sonnet-5
2
+ # model: claude-sonnet-4-5
3
3
  description: This application's agent.
4
4
  limits:
5
5
  max_steps: 25 # model calls per turn
@@ -7,3 +7,12 @@ limits:
7
7
  # max_cost: 1.00 # dollars per turn
8
8
  # timeout: 300 # wall-clock seconds per turn
9
9
 
10
+ # Structured final answers: give the turn's answer a JSON schema and read the
11
+ # parsed Hash from Turn#answer_data (answer_text stays for prose agents).
12
+ # final_answer:
13
+ # type: object
14
+ # properties:
15
+ # verdict: { type: string }
16
+ # amount_pence: { type: integer }
17
+ # required: [verdict]
18
+
@@ -4,11 +4,11 @@ Silas.configure do |config|
4
4
  config.engine = :ruby_llm
5
5
 
6
6
  # Any model your installed ruby_llm's registry resolves (newer models may
7
- # need `RubyLLM.models.refresh!` first). "claude-sonnet-5" is the balanced
7
+ # need `RubyLLM.models.refresh!` first). "claude-sonnet-4-5" is the balanced
8
8
  # default; "claude-haiku-4-5" is fastest/cheapest; Opus models are the most
9
9
  # capable and the most expensive — set per-turn budgets in agent.yml before
10
10
  # reaching for one.
11
- config.default_model = "claude-sonnet-5"
11
+ config.default_model = "claude-sonnet-4-5"
12
12
 
13
13
  # The operator inbox (mounted at /silas/inbox) is DENY-BY-DEFAULT — invisible
14
14
  # until you wire auth. The lambda DENIES by rendering (or head-ing) and
@@ -34,8 +34,9 @@ Silas.configure do |config|
34
34
  # disables memory entirely.
35
35
  # config.memory_approval = :always
36
36
 
37
- # Cost accounting price overrides: cost-units per 1k tokens, where
38
- # 1_000_000 units = $1 (so a $3/M-token rate is 3000).
37
+ # Cost accounting prices itself from RubyLLM's model registry. Override per
38
+ # model for fine-tunes / custom deployments / models newer than your
39
+ # installed registry (units per 1k tokens; 1e6 units = $1):
39
40
  # config.model_prices["your-fine-tune"] = { in: 3000, out: 15_000 }
40
41
 
41
42
  # Where eval scenarios live (bin/rails silas:eval).
data/lib/silas/agent.rb CHANGED
@@ -16,6 +16,10 @@ module Silas
16
16
 
17
17
  def model = @attrs["model"] || Silas.config.default_model
18
18
  def description = @attrs["description"].to_s
19
+ # Optional JSON schema for the turn's final answer (raw Hash, passed to
20
+ # RubyLLM's with_schema). Model-visible state: folded into the definitions
21
+ # digest when present, so a mid-turn change fails loudly.
22
+ def final_answer = @attrs["final_answer"]
19
23
  def limits = @attrs["limits"] || {}
20
24
  def max_steps = limits["max_steps"] || Silas.config.max_steps
21
25
  def max_input_tokens = limits["max_input_tokens"] # cumulative input tokens per turn
data/lib/silas/chat.rb CHANGED
@@ -120,6 +120,8 @@ module Silas
120
120
  when "completed"
121
121
  if @last_streamed.present? && @last_streamed == turn.answer_text
122
122
  @out.puts # the streamed line IS the answer; just terminate it
123
+ elsif turn.answer_text.blank? && (data = turn.answer_data)
124
+ @out.puts "agent> #{JSON.generate(data)}" # final_answer schema: the payload IS the answer
123
125
  else
124
126
  @out.puts "agent> #{turn.answer_text}"
125
127
  end
@@ -39,35 +39,29 @@ module Silas
39
39
  # tools as MCP" seam).
40
40
  attr_accessor :mcp_server_host
41
41
 
42
- # Removed in 0.2 with the :agent_sdk engine. Accepted as warning no-ops for
43
- # one release so an existing initializer doesn't crash at boot; hard removal
44
- # in 0.3.
45
- REMOVED_AGENT_SDK_OPTIONS = %i[
46
- auth agent_sdk_claude_bin agent_sdk_model agent_sdk_mcp_host
47
- agent_sdk_mcp_timeout_ms agent_sdk_cli_version_range
48
- ].freeze
49
-
50
- REMOVED_AGENT_SDK_OPTIONS.each do |option|
51
- define_method("#{option}=") { |_value| warn_removed_agent_sdk_option(option) }
52
- define_method(option) do
53
- warn_removed_agent_sdk_option(option)
54
- nil
55
- end
56
- end
57
-
58
- def warn_removed_agent_sdk_option(option)
59
- message = "[Silas] config.#{option} was removed in 0.2 with the :agent_sdk engine and " \
60
- "is now a no-op — delete it from your initializer (hard removal in 0.3)."
61
- (defined?(::Rails) && ::Rails.logger ? ::Rails.logger.warn(message) : nil) || Kernel.warn(message)
62
- end
42
+ # config.auth and the agent_sdk_* options were removed with the :agent_sdk
43
+ # engine in 0.2 (warning no-ops for one release) and hard-removed in 0.3 —
44
+ # a leftover write now raises NoMethodError. Delete them from your
45
+ # initializer.
46
+ # JSON API (mounted under /silas/api/v1).
47
+ # api_auth — deny-by-default lambda, same contract as inbox_auth: the
48
+ # host DENIES by rendering (or head-ing) and PASSES by not
49
+ # rendering. Wire a token check, Devise, whatever you run.
50
+ # api_actor controller -> identity string recorded on approvals made
51
+ # through the API (approved_by / declined by).
52
+ # api_stream_poll_interval — seconds between SSE row polls.
53
+ # api_stream_max_duration — seconds before an SSE stream closes itself
54
+ # (clients reconnect with Last-Event-ID); bounds thread hold.
55
+ attr_accessor :api_auth, :api_actor, :api_stream_poll_interval, :api_stream_max_duration
63
56
  # Inbox (mountable UI at /silas/inbox).
64
57
  # inbox_auth — deny-by-default lambda; the host renders/head-404s to
65
58
  # DENY and passes by NOT rendering (resilience pattern).
66
59
  # inbox_public_read — reads render for anyone; approve/decline still gated.
67
60
  # inbox_actor — controller -> identity string (approved_by/decline by:).
68
- # model_prices — model id -> {in:, out:} cost-units per 1k tokens,
69
- # where 1_000_000 units = $1 (so a $3/M-token input rate
70
- # is 3000 units/1k). Cost.format divides by 1e6.
61
+ # model_prices — OVERRIDE map: model id -> {in:, out:} cost-units per
62
+ # 1k tokens, 1e6 units = $1 (a $3/M-token rate is
63
+ # 3000). Beats the RubyLLM registry, which prices
64
+ # everything else per (model, provider).
71
65
  attr_accessor :inbox_auth, :inbox_public_read, :inbox_actor, :model_prices
72
66
  # Force-disable live broadcasting even when turbo-rails is present (nil = auto).
73
67
  attr_accessor :inbox_streaming
@@ -92,7 +86,9 @@ module Silas
92
86
  @engine = :ruby_llm
93
87
  # Must be resolvable by the installed ruby_llm's model registry — newer
94
88
  # Claude models may need `RubyLLM.models.refresh!` before they resolve.
95
- @default_model = "claude-opus-4-8"
89
+ # (Sonnet 4.5 ships in every supported registry; never default a first
90
+ # run to the priciest model.)
91
+ @default_model = "claude-sonnet-4-5"
96
92
  @queue_name = :default
97
93
  @around_model_call = nil
98
94
  @approval_ttl = 7.days
@@ -125,18 +121,18 @@ module Silas
125
121
  @sandbox_workdir = "/workspace"
126
122
  @sandbox_docker_bin = "docker"
127
123
  @sandbox_timeout = 30
124
+ @api_auth = ->(controller) { controller.head :not_found } # deny by default
125
+ @api_actor = ->(_controller) { "api" }
126
+ @api_stream_poll_interval = 0.5
127
+ @api_stream_max_duration = 300
128
128
  @inbox_auth = ->(controller) { controller.head :not_found } # deny by default
129
129
  @inbox_public_read = false
130
130
  @inbox_actor = ->(controller) { controller.try(:current_user)&.try(:email) || "inbox" }
131
- # Current Claude rates as cost-units per 1k tokens (1e6 units = $1):
132
- # Opus 4.8 $5/$25, Sonnet $3/$15, Haiku $1/$5 per million tokens.
133
- @model_prices = {
134
- "claude-opus-4-8" => { in: 5000, out: 25_000 },
135
- "claude-sonnet-5" => { in: 3000, out: 15_000 },
136
- "claude-sonnet-4-6" => { in: 3000, out: 15_000 },
137
- "claude-haiku-4-5" => { in: 1000, out: 5000 },
138
- "claude-haiku-4-5-20251001" => { in: 1000, out: 5000 }
139
- }
131
+ # OVERRIDE map only pricing comes from RubyLLM's model registry
132
+ # (1,100+ models, refreshed upstream from models.dev). List a model here
133
+ # to beat the registry: fine-tunes, custom deployments, models newer
134
+ # than the installed registry. Units: per 1k tokens, 1e6 units = $1.
135
+ @model_prices = {}
140
136
  end
141
137
 
142
138
  def validate!
@@ -0,0 +1,155 @@
1
+ require "erb"
2
+ require "yaml"
3
+
4
+ module Silas
5
+ # One command for every known first-run failure mode: provider key, queue
6
+ # adapter, model resolution, migrations, tool validation, the rescuer
7
+ # entry, cable adapter for live streaming, and auth wiring. Each check was
8
+ # already written somewhere in the codebase — this makes them reachable as
9
+ # `bin/rails silas:doctor`.
10
+ class Doctor
11
+ Check = Struct.new(:status, :label, :detail) # status: :pass | :warn | :fail
12
+
13
+ def self.run(root: Rails.root) = new(root: root).run
14
+
15
+ def initialize(root:)
16
+ @root = Pathname(root)
17
+ end
18
+
19
+ def run
20
+ [
21
+ provider_credentials, queue_adapter, model_resolution, migrations,
22
+ agent_directory, rescuer_entry, streaming_cable, auth_wiring
23
+ ].flatten.compact
24
+ end
25
+
26
+ private
27
+
28
+ def provider_credentials
29
+ configured = ::RubyLLM::Provider.providers.select do |_slug, provider|
30
+ requirements = provider.configuration_requirements
31
+ requirements.any? && requirements.all? { |key| ::RubyLLM.config.public_send(key).present? }
32
+ end.keys
33
+ if configured.any?
34
+ Check.new(:pass, "provider credentials", configured.join(", "))
35
+ else
36
+ Check.new(:fail, "provider credentials",
37
+ "no API key on RubyLLM.config — set one in config/initializers/ruby_llm.rb; " \
38
+ "the first turn will fail without it")
39
+ end
40
+ rescue StandardError => e
41
+ Check.new(:warn, "provider credentials", "could not inspect RubyLLM config (#{e.class})")
42
+ end
43
+
44
+ def queue_adapter
45
+ name = ActiveJob::Base.queue_adapter.class.name.to_s
46
+ case name
47
+ when /SolidQueue/
48
+ Check.new(:pass, "queue adapter", "solid_queue (durable)")
49
+ when /AsyncAdapter/
50
+ Check.new(:fail, "queue adapter",
51
+ "in-process :async double-executes continuation steps and voids the durability " \
52
+ "contract — use :solid_queue (see DEPLOY.md)")
53
+ when /InlineAdapter/
54
+ Check.new(:warn, "queue adapter", "inline — fine for scripts and demos, no durability")
55
+ else
56
+ Check.new(:warn, "queue adapter", "#{name.demodulize} — durability requires a serializing, DB-backed adapter")
57
+ end
58
+ end
59
+
60
+ def model_resolution
61
+ model = Silas.agent.model
62
+ info = ::RubyLLM.models.find(model)
63
+ Check.new(:pass, "model #{model}",
64
+ "#{info.provider} · $#{info.input_price_per_million}/$#{info.output_price_per_million} per MTok")
65
+ rescue StandardError
66
+ Check.new(:fail, "model #{model || '?'}",
67
+ "not in ruby_llm's registry — `RubyLLM.models.refresh!` or pick a registry model")
68
+ end
69
+
70
+ def migrations
71
+ missing = %w[silas_sessions silas_turns silas_steps silas_tool_invocations]
72
+ .reject { |t| ActiveRecord::Base.connection.table_exists?(t) }
73
+ if missing.any?
74
+ return Check.new(:fail, "migrations",
75
+ "missing #{missing.join(', ')} — bin/rails silas:install:migrations db:migrate")
76
+ end
77
+ unless ActiveRecord::Base.connection.column_exists?(:silas_steps, :provider)
78
+ return Check.new(:warn, "migrations", "0.3 migration pending — bin/rails silas:install:migrations db:migrate")
79
+ end
80
+
81
+ Check.new(:pass, "migrations", "all silas tables present")
82
+ rescue StandardError => e
83
+ Check.new(:fail, "database", "#{e.class}: #{e.message.lines.first&.strip}")
84
+ end
85
+
86
+ def agent_directory
87
+ dir = @root.join("app/agent")
88
+ return Check.new(:fail, "app/agent", "missing — bin/rails generate silas:install") unless dir.exist?
89
+
90
+ checks = []
91
+ checks << Check.new(:warn, "instructions", "app/agent/instructions.md missing") unless dir.join("instructions.md").exist?
92
+ begin
93
+ registry = Silas::Registry.new(root: @root)
94
+ checks << Check.new(:pass, "tools", "#{registry.tools.size} tool(s) validate")
95
+ rescue StandardError => e
96
+ checks << Check.new(:fail, "tools", e.message)
97
+ end
98
+ checks
99
+ end
100
+
101
+ def rescuer_entry
102
+ path = @root.join("config/recurring.yml")
103
+ unless path.exist?
104
+ return Check.new(:warn, "rescuer",
105
+ "config/recurring.yml missing — the dead-job rescuer is part of the durability contract")
106
+ end
107
+ if path.read.include?("silas_dead_job_rescuer")
108
+ Check.new(:pass, "rescuer", "recurring entry present")
109
+ else
110
+ Check.new(:warn, "rescuer", "no silas_dead_job_rescuer entry — SIGKILL recovery won't run")
111
+ end
112
+ end
113
+
114
+ def streaming_cable
115
+ unless Silas::Inbox.streaming_available?
116
+ return Check.new(:warn, "live streaming", "turbo-rails not bundled — the inbox falls back to a polling refresh")
117
+ end
118
+
119
+ cable = @root.join("config/cable.yml")
120
+ adapter = begin
121
+ cable.exist? ? YAML.safe_load(ERB.new(cable.read).result, aliases: true)&.dig(Rails.env.to_s, "adapter") : nil
122
+ rescue StandardError
123
+ nil
124
+ end
125
+ case adapter
126
+ when "async"
127
+ Check.new(:warn, "live streaming",
128
+ "cable adapter :async is single-process — token deltas emitted in the worker never " \
129
+ "reach the browser; use solid_cable or redis")
130
+ when nil
131
+ Check.new(:warn, "live streaming", "could not read a cable adapter from config/cable.yml")
132
+ else
133
+ Check.new(:pass, "live streaming", "cable adapter #{adapter}")
134
+ end
135
+ end
136
+
137
+ def auth_wiring
138
+ [
139
+ auth_check("inbox auth", Silas.config.inbox_auth, "/silas/inbox", "config.inbox_auth"),
140
+ auth_check("api auth", Silas.config.api_auth, "/silas/api/v1", "config.api_auth")
141
+ ]
142
+ end
143
+
144
+ # The deny-by-default lambdas are defined inside silas/configuration.rb;
145
+ # anything the host wired has a different source_location.
146
+ def auth_check(label, auth_lambda, surface, option)
147
+ if auth_lambda.respond_to?(:source_location) &&
148
+ auth_lambda.source_location&.first.to_s.include?("silas/configuration")
149
+ Check.new(:warn, label, "deny-by-default — #{surface} is invisible until you set #{option}")
150
+ else
151
+ Check.new(:pass, label, "configured")
152
+ end
153
+ end
154
+ end
155
+ end
@@ -43,6 +43,10 @@ module Silas
43
43
  "or pick a registry-known model in config.default_model / agent.yml."
44
44
  end
45
45
  chat.with_instructions(context[:system]) if context[:system].present?
46
+ # agent.yml's final_answer schema: RubyLLM renders the provider's
47
+ # structured-output dialect and JSON-parses the response back to a
48
+ # Hash — which to_result persists as a "structured" block.
49
+ chat.with_schema(context[:final_answer]) if context[:final_answer].present?
46
50
  context[:tools].each { |definition| chat.with_tool(HaltProxy.new(definition)) }
47
51
 
48
52
  replay_history(chat, context[:messages])
@@ -117,8 +121,15 @@ module Silas
117
121
  assistant = chat.messages.reverse.find { |m| m.role.to_s == "assistant" } || response
118
122
 
119
123
  blocks = []
120
- text = assistant.content.to_s
121
- blocks << { "type" => "text", "text" => text } if text.present?
124
+ content = assistant.content
125
+ if content.is_a?(Hash)
126
+ # with_schema active: RubyLLM parsed the response to a Hash. Persist
127
+ # it as its own block type — content.to_s here would write Ruby's
128
+ # Hash#inspect string into the transcript as "text".
129
+ blocks << { "type" => "structured", "data" => content }
130
+ elsif content.to_s.present?
131
+ blocks << { "type" => "text", "text" => content.to_s }
132
+ end
122
133
 
123
134
  tool_calls = (assistant.tool_calls || {}).values.map do |tc|
124
135
  blocks << { "type" => "tool_call", "id" => tc.id, "name" => tc.name,
@@ -39,6 +39,24 @@ module Silas
39
39
  check(matcher === @t.final_text, "final answer #{@t.final_text.inspect} does not match #{matcher.inspect}")
40
40
  end
41
41
 
42
+ # Structured (final_answer) assertions. With a Hash, expects the whole
43
+ # payload; with key/value, one field; with a block, a predicate over
44
+ # the payload. String keys — the payload is stored jsonb.
45
+ def assert_answer_data(expected = :__unset, key: nil, value: :__unset, &pred)
46
+ data = @t.answer_data
47
+ return check(false, "no structured answer (final_answer schema not set, or turn unfinished)") if data.nil?
48
+
49
+ if pred
50
+ check(pred.call(data), "answer_data predicate failed for #{data.inspect}")
51
+ elsif key
52
+ check(data[key.to_s] == value, "answer_data.#{key} expected #{value.inspect}, got #{data[key.to_s].inspect}")
53
+ elsif expected != :__unset
54
+ check(data == expected, "answer_data expected #{expected.inspect}, got #{data.inspect}")
55
+ else
56
+ check(true, nil) # presence alone
57
+ end
58
+ end
59
+
42
60
  # No-hallucinated-price guard: every money amount in the final answer must
43
61
  # trace to a number the agent actually saw (tool results or the user input),
44
62
  # allowing pence<->pounds scaling.
@@ -14,6 +14,7 @@ module Silas
14
14
  def completed? = @turn.completed?
15
15
  def parked? = @turn.parked?
16
16
  def final_text = @turn.answer_text.to_s
17
+ def answer_data = @turn.answer_data
17
18
  def invocations = @turn.tool_invocations.order(:id).to_a
18
19
  def invocations_for(name) = invocations.select { |i| i.tool_name == name.to_s }
19
20
  def results = invocations.map(&:result).compact
@@ -1,45 +1,47 @@
1
1
  module Silas
2
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.
3
+ # Cost is derived at read time from step tokens. Prices come from
4
+ # config.model_prices (the OVERRIDE map custom deployments, fine-tunes,
5
+ # models newer than the installed registry) and fall back to RubyLLM's
6
+ # model registry, priced per (model, provider) 85/1081 registry ids
7
+ # exist under multiple providers at different prices, which is why steps
8
+ # stamp the provider. Unknown stays `unpriced`, never a lying $0.00.
7
9
  module Cost
8
10
  module_function
9
11
 
10
12
  def for_session(session)
11
13
  rows = Silas::Step.joins(:turn)
12
14
  .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
+ .group(:model, :provider)
16
+ .pluck(:model, :provider, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
15
17
  aggregate(rows)
16
18
  end
17
19
 
18
20
  def for_turn(turn)
19
21
  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
+ .group(:model, :provider)
23
+ .pluck(:model, :provider, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
22
24
  aggregate(rows)
23
25
  end
24
26
 
25
27
  def for_agent(agent_name)
26
28
  rows = Silas::Step.joins(turn: :session)
27
29
  .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
+ .group(:model, :provider)
31
+ .pluck(:model, :provider, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
30
32
  aggregate(rows)
31
33
  end
32
34
 
33
35
  def aggregate(rows)
34
36
  input = output = microcents = 0
35
37
  unpriced = false
36
- rows.each do |model, in_tok, out_tok|
38
+ rows.each do |model, provider, in_tok, out_tok|
37
39
  in_tok = in_tok.to_i
38
40
  out_tok = out_tok.to_i
39
41
  input += in_tok
40
42
  output += out_tok
41
- if (price = Silas.config.model_prices[model])
42
- microcents += (in_tok * price[:in] + out_tok * price[:out]) / 1000
43
+ if (rate = rate_for(model, provider))
44
+ microcents += (in_tok * rate[:in] + out_tok * rate[:out]) / 1000
43
45
  else
44
46
  unpriced = true
45
47
  end
@@ -47,6 +49,28 @@ module Silas
47
49
  { input_tokens: input, output_tokens: output, microcents: microcents, unpriced: unpriced }
48
50
  end
49
51
 
52
+ # {in:, out:} in cost-units per 1k tokens (1e6 units = $1), or nil when
53
+ # the model can't be priced. Override map first; then the registry —
54
+ # two-arg find when the step stamped a provider (the bare form
55
+ # tie-breaks by a hardcoded preference list and can price the wrong
56
+ # provider), registry $/MTok converted at x1000. A model with no price
57
+ # data returns nil: unknown is never zero.
58
+ def rate_for(model, provider)
59
+ if (price = Silas.config.model_prices[model])
60
+ return price
61
+ end
62
+ return nil if model.nil?
63
+
64
+ info = provider.present? ? ::RubyLLM.models.find(model, provider) : ::RubyLLM.models.find(model)
65
+ in_pm = info.input_price_per_million
66
+ out_pm = info.output_price_per_million
67
+ return nil unless in_pm && out_pm
68
+
69
+ { in: (in_pm * 1000).round, out: (out_pm * 1000).round }
70
+ rescue StandardError
71
+ nil
72
+ end
73
+
50
74
  # microcents -> "$0.0123" (or nil when unpriced with no priced tokens)
51
75
  def format(cents)
52
76
  return nil if cents.nil?
@@ -50,9 +50,16 @@ module Silas
50
50
 
51
51
  # Text comes from the model's own blocks; tool_use blocks are rebuilt from
52
52
  # the settled invocations so the assistant message and the tool results that
53
- # follow are always a matched set (same ids, same count).
53
+ # follow are always a matched set (same ids, same count). A structured
54
+ # (final_answer) block replays as its JSON text — deterministic (same rows
55
+ # -> same string), and providers need message content, not our block type.
54
56
  def assistant_blocks(step, settled)
55
- text = Array(step.response_blocks).select { |b| b["type"] == "text" }
57
+ text = Array(step.response_blocks).filter_map do |b|
58
+ case b["type"]
59
+ when "text" then b
60
+ when "structured" then { "type" => "text", "text" => JSON.generate(b["data"]) }
61
+ end
62
+ end
56
63
  tools = settled.map do |inv|
57
64
  { "type" => "tool_call", "id" => inv.tool_call_id,
58
65
  "name" => inv.tool_name, "arguments" => inv.arguments || {} }
@@ -23,6 +23,7 @@ module Silas
23
23
  # Definition readers delegate to the scope's parsed agent.yml.
24
24
  def model = scope.agent.model
25
25
  def description = scope.agent.description
26
+ def final_answer = scope.agent.final_answer
26
27
  def limits = scope.agent.limits
27
28
  def max_steps = scope.agent.max_steps
28
29
  def max_input_tokens = scope.agent.max_input_tokens
@@ -100,13 +100,20 @@ module Silas
100
100
  end
101
101
 
102
102
  # Stable across boots for the same agent definition; changes when any tool
103
- # schema (incl. the delegate roster + remote connection tools), or skill
104
- # description, changes.
103
+ # schema (incl. the delegate roster + remote connection tools), skill
104
+ # description, or final_answer schema changes.
105
+ #
106
+ # final_answer is appended ONLY when present: schema-less agents keep a
107
+ # byte-identical digest across upgrades, so turns parked over a deploy
108
+ # never fail NondeterminismError for a key they don't use.
105
109
  def digest
106
- Digest::SHA256.hexdigest(JSON.generate({
107
- tools: definitions,
108
- skills: skills.map { |s| [ s.name, s.description ] }
109
- }))
110
+ payload = { tools: definitions, skills: skills.map { |s| [ s.name, s.description ] } }
111
+ payload[:final_answer] = root_agent.final_answer if root_agent.final_answer.present?
112
+ Digest::SHA256.hexdigest(JSON.generate(payload))
113
+ end
114
+
115
+ def root_agent
116
+ @root_agent ||= Silas::Agent.load(root: @root)
110
117
  end
111
118
 
112
119
  # --- named agents (app/agents/<name>/ — the staff pattern) ---------------
@@ -192,9 +199,12 @@ module Silas
192
199
  builtins["handoff"] = Silas::Tools::Handoff if named && named_agent_dirs.size > 1
193
200
  resolver = ->(n) { (tools[n] || builtins.fetch(n)).new }
194
201
  definitions = (tools.values + builtins.values).map(&:schema)
195
- digest = Digest::SHA256.hexdigest(JSON.generate(tools: definitions, skills: skills.map { |s| [ s.name, s.description ] }))
202
+ loaded_agent = agent || Silas::Agent.load(dir: dir)
203
+ payload = { tools: definitions, skills: skills.map { |s| [ s.name, s.description ] } }
204
+ payload[:final_answer] = loaded_agent.final_answer if loaded_agent.final_answer.present?
205
+ digest = Digest::SHA256.hexdigest(JSON.generate(payload))
196
206
 
197
- Silas::AgentScope.new(name: name, dir: dir, agent: agent || Silas::Agent.load(dir: dir),
207
+ Silas::AgentScope.new(name: name, dir: dir, agent: loaded_agent,
198
208
  resolver: resolver, definitions: definitions, digest: digest, skills: skills)
199
209
  end
200
210
  end
@@ -22,6 +22,7 @@ module Silas
22
22
  stop_reason: result.stop_reason,
23
23
  terminal: result.terminal?,
24
24
  model: turn_model(turn),
25
+ provider: provider_for(turn_model(turn)),
25
26
  input_tokens: result.usage&.dig(:input_tokens),
26
27
  output_tokens: result.usage&.dig(:output_tokens)
27
28
  )
@@ -58,6 +59,7 @@ module Silas
58
59
  messages: MessageBuilder.call(turn, upto_index: index),
59
60
  tools: Silas.tool_definitions,
60
61
  model: turn_model(turn),
62
+ final_answer: Silas.agent.final_answer,
61
63
  limits: { max_steps: Silas.agent.max_steps }
62
64
  }
63
65
 
@@ -109,5 +111,15 @@ module Silas
109
111
  def turn_model(_turn)
110
112
  Silas.agent.model
111
113
  end
114
+
115
+ # The provider RubyLLM's own resolution picks for this model id, stamped
116
+ # on the row so cost lookups price against (model, provider) forever
117
+ # after — the registry's tie-break can change; the row shouldn't. nil for
118
+ # ids the installed registry doesn't know (fakes, custom engines).
119
+ def provider_for(model)
120
+ ::RubyLLM.models.find(model).provider
121
+ rescue StandardError
122
+ nil
123
+ end
112
124
  end
113
125
  end
data/lib/silas/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/silas.rb CHANGED
@@ -43,6 +43,7 @@ require "silas/instructions"
43
43
  require "silas/step_runner"
44
44
  require "silas/eval" # after engines (ScriptedEngine < Engines::Base)
45
45
  require "silas/chat"
46
+ require "silas/doctor"
46
47
 
47
48
  module Silas
48
49
  class << self