Class: Silas::Registry

Inherits:
Object
  • Object
show all
Defined in:
lib/silas/registry.rb

Overview

Discovery by directory convention — no registration. Globs app/agent/, resolves tool classes through Zeitwerk, validates them at boot, and computes the definitions digest that guards against a deploy changing the agent mid-turn (NondeterminismError).

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root: Rails.root) ⇒ Registry

Returns a new instance of Registry.



25
26
27
# File 'lib/silas/registry.rb', line 25

def initialize(root: Rails.root)
  @root = Pathname(root)
end

Class Method Details

.install!(root: Rails.root) ⇒ Object



9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# File 'lib/silas/registry.rb', line 9

def self.install!(root: Rails.root)
  registry = new(root: root)
  Silas.config.tool_resolver = registry.resolver
  Silas.config.tool_definitions = -> { registry.definitions }
  Silas.config.definitions_digest = -> { registry.digest }
  Silas.config.skills = -> { registry.skills }
  Silas.config.schedules = -> { registry.schedules }
  Silas.config.channel_resolver = ->(name) { registry.channels[name] }
  Silas.config.subagent_index = -> { registry.subagent_index }
  Silas.config.subagent_scopes = -> { registry.subagent_scopes }
  Silas.config.agent_override = nil
  Silas.config.instructions_dir = nil
  Silas.reset_agent_memo!
  registry
end

Instance Method Details

#builtinsObject

Built-in harness tools: load_skill when skills exist; delegate when subagents exist (root only — subagents never get delegate: depth-1).



70
71
72
73
74
75
76
# File 'lib/silas/registry.rb', line 70

def builtins
  b = {}
  b["load_skill"] = Silas::Tools::LoadSkill if skills.any?
  b["delegate"] = Silas::Tools::Delegate if subagent_dirs.any?
  b["run_code"] = Silas::Tools::RunCode if Silas.sandbox_enabled?
  b
end

#channelsObject

name => Channel subclass. Filename identity, like tools. Also not in the digest — a channel is a trigger/transport, not a model-visible capability.



58
59
60
61
62
63
64
65
66
# File 'lib/silas/registry.rb', line 58

def channels
  @channels ||= Dir[@root.join("app/agent/channels/*.rb")].sort.to_h do |file|
    name = File.basename(file, ".rb")
    klass = "Agent::Channels::#{name.camelize}".constantize
    raise Error, "#{klass} (from #{file}) must inherit from Silas::Channel" unless klass < Silas::Channel

    [ name, klass ]
  end
end

#connectionsObject

Remote MCP tools from app/agent/connections/*.yml (warmed once at boot).



79
80
81
# File 'lib/silas/registry.rb', line 79

def connections
  @connections ||= Silas::Connections.new(root: @root, client_factory: Silas.config.mcp_client_factory).warm!
end

#definitionsObject



92
93
94
# File 'lib/silas/registry.rb', line 92

def definitions
  (tools.values + builtins.values).map(&:schema) + connections.definitions
end

#digestObject

Stable across boots for the same agent definition; changes when any tool schema (incl. the delegate roster + remote connection tools), or skill description, changes.



99
100
101
102
103
104
# File 'lib/silas/registry.rb', line 99

def digest
  Digest::SHA256.hexdigest(JSON.generate({
    tools: definitions,
    skills: skills.map { |s| [ s.name, s.description ] }
  }))
end

#resolverObject



83
84
85
86
87
88
89
90
# File 'lib/silas/registry.rb', line 83

def resolver
  lambda do |name|
    klass = tools[name] || builtins[name]
    return klass.new if klass

    connections.resolve(name) or raise Error, "unknown tool #{name.inspect}"
  end
end

#schedulesObject

Schedules by filesystem identity (subdirs included). Sorted so identity and compile order are deterministic. Deliberately NOT in #definitions or #digest — a schedule is a trigger, not a model-visible capability.



51
52
53
54
# File 'lib/silas/registry.rb', line 51

def schedules
  @schedules ||= Dir[@root.join("app/agent/schedules/**/*.{md,rb}")].sort
                     .map { |f| Schedule.parse(Pathname(f), root: @root) }
end

#skillsObject



44
45
46
# File 'lib/silas/registry.rb', line 44

def skills
  @skills ||= Dir[@root.join("app/agent/skills/*.md")].sort.map { |f| Skill.parse(f) }
end

#subagent_dirsObject

--- subagents -----------------------------------------------------------



108
109
110
# File 'lib/silas/registry.rb', line 108

def subagent_dirs
  @subagent_dirs ||= Dir[@root.join("app/agent/subagents/*")].select { |p| File.directory?(p) }.sort
end

#subagent_indexObject



112
113
114
115
116
117
# File 'lib/silas/registry.rb', line 112

def subagent_index
  subagent_dirs.map do |dir|
    name = File.basename(dir)
    [ name, subagent_agent(dir, name).description ]
  end
end

#subagent_scopesObject



119
120
121
122
123
124
# File 'lib/silas/registry.rb', line 119

def subagent_scopes
  @subagent_scopes ||= subagent_dirs.to_h do |dir|
    name = File.basename(dir)
    [ name, build_subagent_scope(Pathname(dir), name) ]
  end
end

#toolsObject

tool_name => Class. Filename is identity; the class must match Zeitwerk's expectation for it (Agent::Tools::).



31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/silas/registry.rb', line 31

def tools
  @tools ||= Dir[@root.join("app/agent/tools/*.rb")].sort.to_h do |file|
    name = File.basename(file, ".rb")
    klass = "Agent::Tools::#{name.camelize}".constantize
    unless klass.ancestors.include?(Silas::Tool)
      raise Error, "#{klass} (from #{file}) must inherit from Silas::Tool"
    end

    klass.validate_signature!
    [ name, klass ]
  end
end