silas 0.6.1 → 0.6.3

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +238 -0
  3. data/README.md +11 -5
  4. data/app/controllers/silas/channels/slack_controller.rb +2 -0
  5. data/app/controllers/silas/inbox/sessions_controller.rb +3 -1
  6. data/app/helpers/silas/inbox/trace_helper.rb +224 -2
  7. data/app/mailboxes/silas/agent_mailbox.rb +4 -0
  8. data/app/models/silas/tool_invocation.rb +37 -6
  9. data/app/views/layouts/silas/inbox.html.erb +69 -3
  10. data/app/views/silas/inbox/invocations/_detail.html.erb +21 -0
  11. data/app/views/silas/inbox/invocations/_invocation.html.erb +39 -24
  12. data/app/views/silas/inbox/sessions/_child.html.erb +14 -0
  13. data/app/views/silas/inbox/sessions/_row.html.erb +13 -2
  14. data/app/views/silas/inbox/sessions/show.html.erb +33 -0
  15. data/app/views/silas/inbox/steps/_step.html.erb +8 -1
  16. data/app/views/silas/inbox/turns/_header.html.erb +7 -2
  17. data/docs/agents.md +15 -3
  18. data/docs/channels.md +53 -2
  19. data/docs/configuration.md +6 -1
  20. data/docs/connections.md +8 -5
  21. data/docs/guarantees.md +16 -5
  22. data/docs/headless.md +120 -0
  23. data/docs/providers.md +171 -0
  24. data/docs/traces.md +131 -0
  25. data/docs/tutorial.md +6 -4
  26. data/docs/vs-eve.md +41 -17
  27. data/docs/why-silas.md +6 -6
  28. data/lib/generators/silas/install/install_generator.rb +21 -0
  29. data/lib/generators/silas/install/templates/initializer.rb +5 -0
  30. data/lib/generators/silas/install/templates/ruby_llm.rb +3 -1
  31. data/lib/silas/channel.rb +85 -11
  32. data/lib/silas/configuration.rb +16 -1
  33. data/lib/silas/doctor.rb +35 -2
  34. data/lib/silas/registry.rb +20 -2
  35. data/lib/silas/tool.rb +38 -5
  36. data/lib/silas/version.rb +1 -1
  37. metadata +6 -1
@@ -50,9 +50,23 @@ module Silas
50
50
  # Channels: name -> Channel subclass (wired by the Registry). Slack creds
51
51
  # default to credentials.dig(:silas, :slack, ...); nil disables Slack.
52
52
  attr_accessor :channel_resolver
53
+ # Inbound routing: which named agent an external thread wakes. Data only —
54
+ # { transport => { key => agent_name } }, where transport is the channel's
55
+ # filename identity and key is whatever that transport calls a destination:
56
+ #
57
+ # config.channel_routes = {
58
+ # "slack" => { "C0BILLING" => "bookkeeper" },
59
+ # "email" => { "billing@shop.test" => "bookkeeper" }
60
+ # }
61
+ #
62
+ # Unmatched threads wake the root agent. Names are checked at boot against
63
+ # app/agents/ (Registry.install!) — a typo fails the deploy, never a
64
+ # webhook.
65
+ attr_accessor :channel_routes
53
66
  attr_writer :slack_signing_secret, :slack_bot_token
54
67
  # Bind host for the in-process MCP server (Mcp::Server — the "mount your
55
- # tools as MCP" seam).
68
+ # tools as MCP" seam). Inert: nothing outside Mcp::Server's own specs calls
69
+ # .start, so this setting has no effect until a mounted endpoint ships.
56
70
  attr_accessor :mcp_server_host
57
71
 
58
72
  # Renamed in 0.4, removed in 2.0. "engine" meant two unrelated things —
@@ -134,6 +148,7 @@ module Silas
134
148
  @agent_override = nil
135
149
  @instructions_dir = nil
136
150
  @channel_resolver = nil
151
+ @channel_routes = {}
137
152
  @slack_signing_secret = nil
138
153
  @slack_bot_token = nil
139
154
  @mcp_server_host = "127.0.0.1"
data/lib/silas/doctor.rb CHANGED
@@ -10,6 +10,35 @@ module Silas
10
10
  class Doctor
11
11
  Check = Struct.new(:status, :label, :detail) # status: :pass | :warn | :fail
12
12
 
13
+ # Rails defaults development to :async, which the queue_adapter check below
14
+ # FAILS — so a stock app fails the doctor the installer told it to run. The
15
+ # remedy travels with the failure, and the install generator prints this
16
+ # same constant when it detects :async, so the two surfaces can't drift.
17
+ # Printed, never written: database.yml and cable.yml belong to the host app.
18
+ ASYNC_QUEUE_REMEDY = <<~MSG.freeze
19
+ Add to config/environments/development.rb:
20
+
21
+ config.active_job.queue_adapter = :solid_queue
22
+ config.solid_queue.connects_to = { database: { writing: :queue } }
23
+
24
+ and a queue database to config/database.yml (SQLite shown — drop the
25
+ connects_to line above if Solid Queue shares your primary database):
26
+
27
+ development:
28
+ primary:
29
+ <<: *default
30
+ database: storage/development.sqlite3
31
+ queue:
32
+ <<: *default
33
+ database: storage/development_queue.sqlite3
34
+ migrations_paths: db/queue_migrate
35
+
36
+ Then `bin/rails db:prepare`, and run the worker next to the server:
37
+ `bin/jobs`. On an app that predates Rails 8, `bundle add solid_queue &&
38
+ bin/rails solid_queue:install` first. For scripts and demos `:inline` is
39
+ also safe — synchronous, no durability.
40
+ MSG
41
+
13
42
  def self.run(root: Rails.root) = new(root: root).run
14
43
 
15
44
  def initialize(root:)
@@ -49,11 +78,15 @@ module Silas
49
78
  when /AsyncAdapter/
50
79
  Check.new(:fail, "queue adapter",
51
80
  "in-process :async double-executes continuation steps and voids the durability " \
52
- "contract — use :solid_queue (see DEPLOY.md)")
81
+ "contract — use :solid_queue (see DEPLOY.md)\n\n#{ASYNC_QUEUE_REMEDY.indent(3).chomp}")
53
82
  when /InlineAdapter/
54
83
  Check.new(:warn, "queue adapter", "inline — fine for scripts and demos, no durability")
55
84
  else
56
- Check.new(:warn, "queue adapter", "#{name.demodulize} — durability requires a serializing, DB-backed adapter")
85
+ Check.new(:warn, "queue adapter",
86
+ "#{name.demodulize} — no dead-job rescue path: DeadJobRescuerJob sweeps expired approvals " \
87
+ "but returns early unless SolidQueue is defined, so a job killed with its worker is never " \
88
+ "retried and a turn stranded mid-tool stays running with its in-doubt invocation unswept. " \
89
+ "Durability requires a serializing, DB-backed adapter.")
57
90
  end
58
91
  end
59
92
 
@@ -20,6 +20,10 @@ module Silas
20
20
  Silas.config.agent_override = nil
21
21
  Silas.config.instructions_dir = nil
22
22
  Silas.reset_agent_memo!
23
+ # Boot is the last place a bad channel route can fail safely. Directory
24
+ # names, not built scopes: validation must not force every named agent's
25
+ # tools to constantize before anything needs them.
26
+ Silas::Channel.validate_routes!(registry.named_agent_dirs.map { |dir| File.basename(dir) })
23
27
  registry
24
28
  end
25
29
 
@@ -182,6 +186,13 @@ module Silas
182
186
  # identity under const_base, skills, the load_skill builtin when skills
183
187
  # exist, run_code when asked, and the scope's own digest (the same
184
188
  # NondeterminismError guard root turns get).
189
+ #
190
+ # A named agent is a full member of staff: it gets ask_question (parking to
191
+ # ask a person is the premise, not a root-agent privilege) and the shared
192
+ # remote connections. Connections live in the root app/agent/connections/ —
193
+ # one set of credentials for the app, reachable by everyone. Subagents get
194
+ # neither: they run inside a parent's turn, which is where the human contact
195
+ # and the remote surface belong.
185
196
  def build_agent_scope(dir, name, const_base:, agent: nil, run_code: false, named: false)
186
197
  tools = Dir[dir.join("tools/*.rb")].sort.to_h do |file|
187
198
  tname = File.basename(file, ".rb")
@@ -193,6 +204,7 @@ module Silas
193
204
  end
194
205
  skills = Dir[dir.join("skills/*.md")].sort.map { |f| Skill.parse(f) }
195
206
  builtins = {}
207
+ builtins["ask_question"] = Silas::Tools::AskQuestion if named && Silas.config.ask_question
196
208
  builtins["load_skill"] = Silas::Tools::LoadSkill if skills.any?
197
209
  builtins["run_code"] = Silas::Tools::RunCode if run_code
198
210
  if named && Silas.memory_enabled?
@@ -200,8 +212,14 @@ module Silas
200
212
  builtins["recall"] = Silas::Tools::Recall
201
213
  end
202
214
  builtins["handoff"] = Silas::Tools::Handoff if named && named_agent_dirs.size > 1
203
- resolver = ->(n) { (tools[n] || builtins.fetch(n)).new }
204
- definitions = (tools.values + builtins.values).map(&:schema)
215
+ remote = connections if named
216
+ resolver = lambda do |n|
217
+ klass = tools[n] || builtins[n]
218
+ return klass.new if klass
219
+
220
+ remote&.resolve(n) or raise Error, "unknown tool #{n.inspect} for agent #{name.inspect}"
221
+ end
222
+ definitions = (tools.values + builtins.values).map(&:schema) + (remote&.definitions || [])
205
223
  loaded_agent = agent || Silas::Agent.load(dir: dir)
206
224
  payload = { tools: definitions, skills: skills.map { |s| [ s.name, s.description ] } }
207
225
  payload[:final_answer] = loaded_agent.final_answer if loaded_agent.final_answer.present?
data/lib/silas/tool.rb CHANGED
@@ -23,16 +23,16 @@ module Silas
23
23
  class << self
24
24
  def description(text = nil)
25
25
  @description = text if text
26
- @description || ""
26
+ inherited_setting(:@description) || ""
27
27
  end
28
28
 
29
29
  def param(name, type = :string, desc: nil)
30
- param_refinements[name.to_sym] = { type: type.to_s, desc: desc }
30
+ own_param_refinements[name.to_sym] = { type: type.to_s, desc: desc }
31
31
  end
32
32
 
33
33
  def approval(policy = nil, &block)
34
34
  @approval_policy = block || policy unless policy.nil? && block.nil?
35
- @approval_policy || :never
35
+ inherited_setting(:@approval_policy) || :never
36
36
  end
37
37
  alias approval_policy approval
38
38
 
@@ -40,7 +40,7 @@ module Silas
40
40
  def idempotent! = @effect_mode = :idempotent
41
41
  def at_most_once! = @effect_mode = :at_most_once
42
42
 
43
- def effect_mode = @effect_mode || :at_most_once
43
+ def effect_mode = inherited_setting(:@effect_mode) || :at_most_once
44
44
 
45
45
  def tool_name
46
46
  name.demodulize.underscore
@@ -79,9 +79,42 @@ module Silas
79
79
 
80
80
  private
81
81
 
82
- def param_refinements
82
+ # Ruby does NOT inherit class-level instance variables. Without this walk,
83
+ # factoring shared declarations into a base class —
84
+ #
85
+ # class MoneyTool < Silas::Tool
86
+ # transactional!
87
+ # approval :always
88
+ # end
89
+ # class IssueRefund < MoneyTool; end
90
+ #
91
+ # — silently drops both in the subclass, and drops them into the LEAST
92
+ # safe defaults: no database transaction (:at_most_once) and no human
93
+ # gate (:never). Silently disarming the two declarations the ledger acts
94
+ # on is the worst failure this DSL could have, so settings resolve up the
95
+ # ancestry: nearest explicit declaration wins.
96
+ def inherited_setting(ivar)
97
+ klass = self
98
+ while klass.respond_to?(:inherited_setting, true)
99
+ value = klass.instance_variable_get(ivar)
100
+ return value unless value.nil?
101
+
102
+ klass = klass.superclass
103
+ end
104
+ nil
105
+ end
106
+
107
+ def own_param_refinements
83
108
  @param_refinements ||= {}
84
109
  end
110
+
111
+ # Merged down the ancestry so a base class's `param` refinements survive;
112
+ # a subclass redeclaring the same param wins.
113
+ def param_refinements
114
+ return own_param_refinements unless superclass.respond_to?(:param_refinements, true)
115
+
116
+ superclass.send(:param_refinements).merge(own_param_refinements)
117
+ end
85
118
  end
86
119
 
87
120
  # Instance-side delegation so a resolved instance answers the Ledger.
data/lib/silas/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.6.1"
2
+ VERSION = "0.6.3"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: silas
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel St Paul
@@ -146,7 +146,9 @@ files:
146
146
  - app/views/silas/channels/approvals/error.html.erb
147
147
  - app/views/silas/channels/approvals/show.html.erb
148
148
  - app/views/silas/inbox/invocations/_approval_card.html.erb
149
+ - app/views/silas/inbox/invocations/_detail.html.erb
149
150
  - app/views/silas/inbox/invocations/_invocation.html.erb
151
+ - app/views/silas/inbox/sessions/_child.html.erb
150
152
  - app/views/silas/inbox/sessions/_cost.html.erb
151
153
  - app/views/silas/inbox/sessions/_row.html.erb
152
154
  - app/views/silas/inbox/sessions/index.html.erb
@@ -175,10 +177,13 @@ files:
175
177
  - docs/conventions.md
176
178
  - docs/evals.md
177
179
  - docs/guarantees.md
180
+ - docs/headless.md
178
181
  - docs/inbox-and-api.md
179
182
  - docs/memory.md
183
+ - docs/providers.md
180
184
  - docs/sandbox.md
181
185
  - docs/tools.md
186
+ - docs/traces.md
182
187
  - docs/tutorial.md
183
188
  - docs/vs-eve.md
184
189
  - docs/why-silas.md