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).

Constant Summary collapse

RESERVED_AGENT_NAMES =

--- named agents (app/agents// — the staff pattern) ---------------

%w[agent shared].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root: Rails.root) ⇒ Registry

Returns a new instance of Registry.



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

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
24
# 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.named_agent_scopes = -> { registry.named_agent_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).



71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/silas/registry.rb', line 71

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?
  if Silas.memory_enabled?
    b["remember"] = Silas::Tools::Remember
    b["recall"] = Silas::Tools::Recall
  end
  b["handoff"] = Silas::Tools::Handoff if named_agent_dirs.any?
  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.



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

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).



85
86
87
# File 'lib/silas/registry.rb', line 85

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

#definitionsObject



98
99
100
# File 'lib/silas/registry.rb', line 98

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.



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

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

#named_agent_dirsObject



116
117
118
# File 'lib/silas/registry.rb', line 116

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

#named_agent_scopesObject

{ "clerk" => AgentScope, ... }. Each named agent is a full top-level agent: its own instructions.md, agent.yml, tools/, skills/ — autoloaded under Agents::. Sessions stamped with the name run every turn under this scope (loop-enforced, resume-safe, thread-isolated).



124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/silas/registry.rb', line 124

def named_agent_scopes
  @named_agent_scopes ||= named_agent_dirs.to_h do |dir|
    name = File.basename(dir)
    if RESERVED_AGENT_NAMES.include?(name)
      raise Error, "app/agents/#{name} collides with a reserved name — " \
                   "'agent' is the root app/agent; rename the directory"
    end

    [ name, build_agent_scope(Pathname(dir), name, const_base: "Agents::#{name.camelize}",
                                                   run_code: Silas.sandbox_enabled?, named: true) ]
  end
end

#resolverObject



89
90
91
92
93
94
95
96
# File 'lib/silas/registry.rb', line 89

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.



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

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

#skillsObject



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

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

#subagent_dirsObject

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



139
140
141
# File 'lib/silas/registry.rb', line 139

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

#subagent_indexObject



143
144
145
146
147
148
# File 'lib/silas/registry.rb', line 143

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

#subagent_scopesObject



150
151
152
153
154
155
# File 'lib/silas/registry.rb', line 150

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::).



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

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