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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/LICENSE +21 -0
  4. data/README.md +135 -0
  5. data/app/controllers/silas/channels/approvals_controller.rb +34 -0
  6. data/app/controllers/silas/channels/base_controller.rb +22 -0
  7. data/app/controllers/silas/channels/slack_controller.rb +58 -0
  8. data/app/controllers/silas/inbox/base_controller.rb +37 -0
  9. data/app/controllers/silas/inbox/invocations_controller.rb +44 -0
  10. data/app/controllers/silas/inbox/sessions_controller.rb +16 -0
  11. data/app/helpers/silas/inbox/trace_helper.rb +42 -0
  12. data/app/jobs/silas/agent_loop_job.rb +95 -0
  13. data/app/jobs/silas/channel_delivery_job.rb +62 -0
  14. data/app/jobs/silas/dead_job_rescuer_job.rb +31 -0
  15. data/app/jobs/silas/schedule_job.rb +22 -0
  16. data/app/mailboxes/silas/agent_mailbox.rb +35 -0
  17. data/app/mailers/silas/channel_mailer.rb +17 -0
  18. data/app/models/concerns/silas/inbox/broadcastable.rb +86 -0
  19. data/app/models/silas/application_record.rb +5 -0
  20. data/app/models/silas/session.rb +36 -0
  21. data/app/models/silas/step.rb +33 -0
  22. data/app/models/silas/tool_invocation.rb +78 -0
  23. data/app/models/silas/turn.rb +58 -0
  24. data/app/views/layouts/silas/inbox.html.erb +91 -0
  25. data/app/views/silas/channel_mailer/approval.text.erb +9 -0
  26. data/app/views/silas/channels/approvals/done.html.erb +2 -0
  27. data/app/views/silas/channels/approvals/error.html.erb +2 -0
  28. data/app/views/silas/channels/approvals/show.html.erb +9 -0
  29. data/app/views/silas/inbox/invocations/_approval_card.html.erb +11 -0
  30. data/app/views/silas/inbox/invocations/_invocation.html.erb +11 -0
  31. data/app/views/silas/inbox/sessions/_cost.html.erb +1 -0
  32. data/app/views/silas/inbox/sessions/index.html.erb +23 -0
  33. data/app/views/silas/inbox/sessions/show.html.erb +20 -0
  34. data/app/views/silas/inbox/steps/_step.html.erb +8 -0
  35. data/app/views/silas/inbox/turns/_header.html.erb +7 -0
  36. data/app/views/silas/inbox/turns/_turn.html.erb +8 -0
  37. data/config/routes.rb +19 -0
  38. data/db/migrate/20260714000001_create_silas_tables.rb +78 -0
  39. data/db/migrate/20260715000001_add_channel_outbound_markers.rb +8 -0
  40. data/db/migrate/20260715000002_add_agent_sdk_columns_to_turns.rb +9 -0
  41. data/db/migrate/20260715000003_add_parent_session_to_silas_sessions.rb +8 -0
  42. data/lib/generators/silas/install/install_generator.rb +81 -0
  43. data/lib/generators/silas/install/templates/agent.yml +9 -0
  44. data/lib/generators/silas/install/templates/bin_ci +7 -0
  45. data/lib/generators/silas/install/templates/channel_email.rb +18 -0
  46. data/lib/generators/silas/install/templates/channel_slack.rb +19 -0
  47. data/lib/generators/silas/install/templates/example_eval.rb +22 -0
  48. data/lib/generators/silas/install/templates/example_schedule.md +11 -0
  49. data/lib/generators/silas/install/templates/example_skill.md +8 -0
  50. data/lib/generators/silas/install/templates/example_tool.rb +12 -0
  51. data/lib/generators/silas/install/templates/initializer.rb +16 -0
  52. data/lib/generators/silas/install/templates/instructions.md +4 -0
  53. data/lib/silas/agent.rb +33 -0
  54. data/lib/silas/agent_scope.rb +6 -0
  55. data/lib/silas/agent_sdk/cli.rb +59 -0
  56. data/lib/silas/agent_sdk/stream_parser.rb +86 -0
  57. data/lib/silas/agent_sdk/version_guard.rb +26 -0
  58. data/lib/silas/budget.rb +42 -0
  59. data/lib/silas/channel.rb +69 -0
  60. data/lib/silas/configuration.rb +164 -0
  61. data/lib/silas/connection.rb +77 -0
  62. data/lib/silas/connections.rb +52 -0
  63. data/lib/silas/engine.rb +36 -0
  64. data/lib/silas/engines/agent_sdk.rb +75 -0
  65. data/lib/silas/engines/base.rb +30 -0
  66. data/lib/silas/engines/ruby_llm.rb +136 -0
  67. data/lib/silas/errors.rb +26 -0
  68. data/lib/silas/eval/assertions.rb +107 -0
  69. data/lib/silas/eval/driver.rb +47 -0
  70. data/lib/silas/eval/dsl.rb +55 -0
  71. data/lib/silas/eval/grader.rb +37 -0
  72. data/lib/silas/eval/result.rb +28 -0
  73. data/lib/silas/eval/runner.rb +38 -0
  74. data/lib/silas/eval/scripted_engine.rb +39 -0
  75. data/lib/silas/eval/transcript.rb +22 -0
  76. data/lib/silas/eval.rb +35 -0
  77. data/lib/silas/inbox/cost.rb +58 -0
  78. data/lib/silas/inbox.rb +23 -0
  79. data/lib/silas/instructions.rb +55 -0
  80. data/lib/silas/ledger.rb +178 -0
  81. data/lib/silas/mcp/client.rb +86 -0
  82. data/lib/silas/mcp/handler.rb +89 -0
  83. data/lib/silas/mcp/server.rb +134 -0
  84. data/lib/silas/message_builder.rb +63 -0
  85. data/lib/silas/nested_runner.rb +41 -0
  86. data/lib/silas/registry.rb +155 -0
  87. data/lib/silas/sandbox/docker.rb +67 -0
  88. data/lib/silas/sandbox/null.rb +17 -0
  89. data/lib/silas/sandbox.rb +15 -0
  90. data/lib/silas/schedule/compiler.rb +52 -0
  91. data/lib/silas/schedule.rb +109 -0
  92. data/lib/silas/skill.rb +21 -0
  93. data/lib/silas/slack.rb +55 -0
  94. data/lib/silas/step_runner.rb +98 -0
  95. data/lib/silas/subprocess_runner.rb +41 -0
  96. data/lib/silas/tool.rb +93 -0
  97. data/lib/silas/tools/delegate.rb +42 -0
  98. data/lib/silas/tools/load_skill.rb +25 -0
  99. data/lib/silas/tools/run_code.rb +21 -0
  100. data/lib/silas/version.rb +3 -0
  101. data/lib/silas.rb +144 -0
  102. data/lib/tasks/silas_eval.rake +20 -0
  103. data/lib/tasks/silas_schedules.rake +33 -0
  104. metadata +232 -0
@@ -0,0 +1,155 @@
1
+ require "digest"
2
+
3
+ module Silas
4
+ # Discovery by directory convention — no registration. Globs app/agent/,
5
+ # resolves tool classes through Zeitwerk, validates them at boot, and
6
+ # computes the definitions digest that guards against a deploy changing the
7
+ # agent mid-turn (NondeterminismError).
8
+ class Registry
9
+ def self.install!(root: Rails.root)
10
+ registry = new(root: root)
11
+ Silas.config.tool_resolver = registry.resolver
12
+ Silas.config.tool_definitions = -> { registry.definitions }
13
+ Silas.config.definitions_digest = -> { registry.digest }
14
+ Silas.config.skills = -> { registry.skills }
15
+ Silas.config.schedules = -> { registry.schedules }
16
+ Silas.config.channel_resolver = ->(name) { registry.channels[name] }
17
+ Silas.config.subagent_index = -> { registry.subagent_index }
18
+ Silas.config.subagent_scopes = -> { registry.subagent_scopes }
19
+ Silas.config.agent_override = nil
20
+ Silas.config.instructions_dir = nil
21
+ Silas.reset_agent_memo!
22
+ registry
23
+ end
24
+
25
+ def initialize(root: Rails.root)
26
+ @root = Pathname(root)
27
+ end
28
+
29
+ # tool_name => Class. Filename is identity; the class must match Zeitwerk's
30
+ # expectation for it (Agent::Tools::<Camelized>).
31
+ def tools
32
+ @tools ||= Dir[@root.join("app/agent/tools/*.rb")].sort.to_h do |file|
33
+ name = File.basename(file, ".rb")
34
+ klass = "Agent::Tools::#{name.camelize}".constantize
35
+ unless klass.ancestors.include?(Silas::Tool)
36
+ raise Error, "#{klass} (from #{file}) must inherit from Silas::Tool"
37
+ end
38
+
39
+ klass.validate_signature!
40
+ [ name, klass ]
41
+ end
42
+ end
43
+
44
+ def skills
45
+ @skills ||= Dir[@root.join("app/agent/skills/*.md")].sort.map { |f| Skill.parse(f) }
46
+ end
47
+
48
+ # Schedules by filesystem identity (subdirs included). Sorted so identity
49
+ # and compile order are deterministic. Deliberately NOT in #definitions or
50
+ # #digest — a schedule is a trigger, not a model-visible capability.
51
+ def schedules
52
+ @schedules ||= Dir[@root.join("app/agent/schedules/**/*.{md,rb}")].sort
53
+ .map { |f| Schedule.parse(Pathname(f), root: @root) }
54
+ end
55
+
56
+ # name => Channel subclass. Filename identity, like tools. Also not in the
57
+ # digest — a channel is a trigger/transport, not a model-visible capability.
58
+ def channels
59
+ @channels ||= Dir[@root.join("app/agent/channels/*.rb")].sort.to_h do |file|
60
+ name = File.basename(file, ".rb")
61
+ klass = "Agent::Channels::#{name.camelize}".constantize
62
+ raise Error, "#{klass} (from #{file}) must inherit from Silas::Channel" unless klass < Silas::Channel
63
+
64
+ [ name, klass ]
65
+ end
66
+ end
67
+
68
+ # Built-in harness tools: load_skill when skills exist; delegate when
69
+ # subagents exist (root only — subagents never get delegate: depth-1).
70
+ def builtins
71
+ b = {}
72
+ b["load_skill"] = Silas::Tools::LoadSkill if skills.any?
73
+ b["delegate"] = Silas::Tools::Delegate if subagent_dirs.any?
74
+ b["run_code"] = Silas::Tools::RunCode if Silas.sandbox_enabled?
75
+ b
76
+ end
77
+
78
+ # Remote MCP tools from app/agent/connections/*.yml (warmed once at boot).
79
+ def connections
80
+ @connections ||= Silas::Connections.new(root: @root, client_factory: Silas.config.mcp_client_factory).warm!
81
+ end
82
+
83
+ def resolver
84
+ lambda do |name|
85
+ klass = tools[name] || builtins[name]
86
+ return klass.new if klass
87
+
88
+ connections.resolve(name) or raise Error, "unknown tool #{name.inspect}"
89
+ end
90
+ end
91
+
92
+ def definitions
93
+ (tools.values + builtins.values).map(&:schema) + connections.definitions
94
+ end
95
+
96
+ # Stable across boots for the same agent definition; changes when any tool
97
+ # schema (incl. the delegate roster + remote connection tools), or skill
98
+ # description, changes.
99
+ def digest
100
+ Digest::SHA256.hexdigest(JSON.generate({
101
+ tools: definitions,
102
+ skills: skills.map { |s| [ s.name, s.description ] }
103
+ }))
104
+ end
105
+
106
+ # --- subagents -----------------------------------------------------------
107
+
108
+ def subagent_dirs
109
+ @subagent_dirs ||= Dir[@root.join("app/agent/subagents/*")].select { |p| File.directory?(p) }.sort
110
+ end
111
+
112
+ def subagent_index
113
+ subagent_dirs.map do |dir|
114
+ name = File.basename(dir)
115
+ [ name, subagent_agent(dir, name).description ]
116
+ end
117
+ end
118
+
119
+ def subagent_scopes
120
+ @subagent_scopes ||= subagent_dirs.to_h do |dir|
121
+ name = File.basename(dir)
122
+ [ name, build_subagent_scope(Pathname(dir), name) ]
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def subagent_agent(dir, name)
129
+ agent = Silas::Agent.load(dir: dir)
130
+ raise Error, "subagent #{name}: agent.yml must set `description`" if agent.description.blank?
131
+
132
+ agent
133
+ end
134
+
135
+ def build_subagent_scope(dir, name)
136
+ const_base = "Agent::Subagents::#{name.camelize}"
137
+ tools = Dir[dir.join("tools/*.rb")].sort.to_h do |file|
138
+ tname = File.basename(file, ".rb")
139
+ klass = "#{const_base}::Tools::#{tname.camelize}".constantize
140
+ raise Error, "#{klass} must inherit from Silas::Tool" unless klass.ancestors.include?(Silas::Tool)
141
+
142
+ klass.validate_signature!
143
+ [ tname, klass ]
144
+ end
145
+ skills = Dir[dir.join("skills/*.md")].sort.map { |f| Skill.parse(f) }
146
+ builtins = skills.any? ? { "load_skill" => Silas::Tools::LoadSkill } : {}
147
+ resolver = ->(n) { (tools[n] || builtins.fetch(n)).new }
148
+ definitions = (tools.values + builtins.values).map(&:schema)
149
+ digest = Digest::SHA256.hexdigest(JSON.generate(tools: definitions, skills: skills.map { |s| [ s.name, s.description ] }))
150
+
151
+ Silas::AgentScope.new(name: name, dir: dir, agent: subagent_agent(dir, name),
152
+ resolver: resolver, definitions: definitions, digest: digest, skills: skills)
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,67 @@
1
+ require "securerandom"
2
+ require "open3"
3
+
4
+ module Silas
5
+ module Sandbox
6
+ # Interim adapter: shells to an ephemeral, locked-down `docker run` container
7
+ # (no network, read-only fs, dropped caps, no-new-privileges, memory/cpu/pid
8
+ # limits, host-side timeout). Weaker than a microVM — flag honestly. The argv
9
+ # is a pure function (unit-tested); the exec runner is injectable so the
10
+ # command building is testable without Docker present.
11
+ class Docker
12
+ TIMEOUT_EXIT = 124 # coreutils convention
13
+
14
+ def initialize(image:, network: "none", memory: "512m", cpus: "1", pids: 256,
15
+ workdir: "/workspace", docker_bin: "docker", runner: nil)
16
+ raise SandboxError, "config.sandbox_image is required for :docker" if image.to_s.strip.empty?
17
+
18
+ @image = image
19
+ @network = network
20
+ @memory = memory
21
+ @cpus = cpus
22
+ @pids = pids
23
+ @workdir = workdir
24
+ @docker_bin = docker_bin
25
+ @runner = runner || method(:shell_capture)
26
+ end
27
+
28
+ def enabled? = true
29
+
30
+ def run(command, files: {}, timeout: 30)
31
+ if Silas::Ledger.in_transaction?
32
+ raise SandboxError, "sandbox exec inside a ledger transaction — sandbox-backed tools must be at_most_once!, never transactional!"
33
+ end
34
+ raise SandboxError, "files: is not supported in v1 (deferred)" unless files.empty?
35
+
36
+ name = "silas-sbx-#{SecureRandom.hex(6)}"
37
+ out, err, status = @runner.call(docker_argv(command, name: name), timeout: timeout, name: name)
38
+ Result.new(stdout: out, stderr: err, exit_status: status)
39
+ end
40
+
41
+ # Pure: command is a single argv element to `sh -c`, never interpolated into
42
+ # a shell string — no injection surface.
43
+ def docker_argv(command, name:)
44
+ [ @docker_bin, "run", "--rm", "--name", name,
45
+ "--network", @network,
46
+ "--memory", @memory, "--cpus", @cpus.to_s, "--pids-limit", @pids.to_s,
47
+ "--read-only", "--tmpfs", @workdir, "--workdir", @workdir,
48
+ "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
49
+ @image, "/bin/sh", "-c", command ]
50
+ end
51
+
52
+ private
53
+
54
+ def shell_capture(argv, timeout:, name:)
55
+ reaper = Thread.new do
56
+ sleep(timeout)
57
+ system(@docker_bin, "kill", name, out: File::NULL, err: File::NULL)
58
+ end
59
+ out, err, status = Open3.capture3(*argv)
60
+ reaper.kill
61
+ status.termsig ? [ "", "sandbox timed out after #{timeout}s", TIMEOUT_EXIT ] : [ out, err, status.exitstatus ]
62
+ rescue Errno::ENOENT
63
+ raise SandboxError, "docker binary #{@docker_bin.inspect} not found on PATH"
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,17 @@
1
+ module Silas
2
+ module Sandbox
3
+ # The default: code execution is off. run_code isn't even advertised to the
4
+ # model unless a real sandbox is configured, so this only fires on direct
5
+ # misconfiguration — and it fails loud (SandboxDisabledError is a Silas::Error,
6
+ # so the Ledger propagates it rather than silently failing the invocation).
7
+ class Null
8
+ def enabled? = false
9
+
10
+ def run(_command, files: {}, timeout: nil)
11
+ raise SandboxDisabledError,
12
+ "code execution is disabled (config.sandbox = :none). " \
13
+ "Set config.sandbox = :docker (and config.sandbox_image) to run untrusted commands."
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ module Silas
2
+ # A Sandbox runs a command in isolation and returns its captured output.
3
+ # Duck-typed like the engine seam: #run(command, files:, timeout:) -> Result,
4
+ # #enabled?. Default is Null (code execution off). :docker is the interim
5
+ # adapter — honestly weaker than eve's per-agent microVM (needs Docker present,
6
+ # container isolation not a VM), but real isolation for untrusted/model code.
7
+ module Sandbox
8
+ Result = Struct.new(:stdout, :stderr, :exit_status, keyword_init: true) do
9
+ def success? = exit_status.to_i.zero?
10
+ end
11
+ end
12
+ end
13
+
14
+ require "silas/sandbox/null"
15
+ require "silas/sandbox/docker"
@@ -0,0 +1,52 @@
1
+ require "yaml"
2
+
3
+ module Silas
4
+ class Schedule
5
+ # Discovery alone doesn't schedule anything: Solid Queue's scheduler reads
6
+ # config/recurring.yml at its own boot, and a running scheduler can't be
7
+ # appended to cleanly. So `bin/rails silas:schedules` compiles the discovered
8
+ # schedules into recurring.yml as a reviewable git diff — cron that fires
9
+ # real side effects stays explicit.
10
+ #
11
+ # Merge is key-scoped: only `silas_schedule_*` keys are managed. Hand-written
12
+ # entries (e.g. silas_dead_job_rescuer) are preserved.
13
+ module Compiler
14
+ module_function
15
+
16
+ MANAGED_PREFIX = "silas_schedule_".freeze
17
+
18
+ # { recurring_key => entry_hash } for the given schedules.
19
+ def render(schedules)
20
+ schedules.sort_by(&:name).to_h { |s| [ s.recurring_key, s.recurring_entry ] }
21
+ end
22
+
23
+ # Merge managed entries into recurring.yml under `env`, preserving
24
+ # everything else. Returns the written YAML string.
25
+ def write!(schedules, root:, env: "production")
26
+ path = root.join("config/recurring.yml")
27
+ doc = path.exist? ? (YAML.safe_load(path.read) || {}) : {}
28
+ doc[env] ||= {}
29
+ # Drop stale managed keys, then merge current ones.
30
+ doc[env].reject! { |k, _| k.to_s.start_with?(MANAGED_PREFIX) }
31
+ doc[env].merge!(render(schedules))
32
+ doc[env] = doc[env].sort.to_h
33
+ yaml = YAML.dump(doc)
34
+ path.write(yaml)
35
+ yaml
36
+ end
37
+
38
+ # Names present as files but not compiled into recurring.yml (and vice
39
+ # versa) — the doctor for `silas:schedules:list`.
40
+ def drift(schedules, root:, env: "production")
41
+ path = root.join("config/recurring.yml")
42
+ compiled = path.exist? ? ((YAML.safe_load(path.read) || {}).dig(env) || {}) : {}
43
+ compiled_keys = compiled.keys.select { |k| k.to_s.start_with?(MANAGED_PREFIX) }
44
+ discovered_keys = schedules.map(&:recurring_key)
45
+ {
46
+ uncompiled: schedules.reject { |s| compiled_keys.include?(s.recurring_key) }.map(&:name),
47
+ orphaned: compiled_keys - discovered_keys
48
+ }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,109 @@
1
+ require "yaml"
2
+
3
+ module Silas
4
+ # A schedule is a TRIGGER: it fires on a cron cadence and starts a normal,
5
+ # fully-durable turn. Two forms (eve's shapes):
6
+ # .md with cron frontmatter — "task mode": the body becomes the turn input,
7
+ # output discarded (fire-and-forget).
8
+ # .rb subclassing Silas::Schedule::Handler — programmatic control (fan-out,
9
+ # continue an existing session, conditional start).
10
+ #
11
+ # Identity is the filesystem path under app/agent/schedules (subdirs included):
12
+ # schedules/billing/sweep.rb -> "billing/sweep" -> Agent::Schedules::Billing::Sweep.
13
+ # Schedules are NOT model-visible capabilities, so they never enter the
14
+ # definitions digest — adding or removing one cannot diverge an in-flight turn.
15
+ class Schedule
16
+ KINDS = %i[task handler].freeze
17
+
18
+ attr_reader :name, :cron, :queue, :kind, :payload
19
+
20
+ def initialize(name:, cron:, queue:, kind:, payload:)
21
+ @name = name
22
+ @cron = cron
23
+ @queue = queue
24
+ @kind = kind
25
+ @payload = payload
26
+ end
27
+
28
+ def self.parse(path, root:)
29
+ rel = path.relative_path_from(root.join("app/agent/schedules"))
30
+ name = rel.sub_ext("").to_s
31
+ case path.extname
32
+ when ".md" then from_markdown(name, path)
33
+ when ".rb"
34
+ const = "Agent::Schedules::" + name.split("/").map(&:camelize).join("::")
35
+ from_handler(name, const.constantize)
36
+ end
37
+ end
38
+
39
+ def self.from_markdown(name, path)
40
+ content = File.read(path)
41
+ if content =~ /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
42
+ frontmatter = YAML.safe_load(Regexp.last_match(1)) || {}
43
+ body = Regexp.last_match(2)
44
+ else
45
+ frontmatter = {}
46
+ body = content
47
+ end
48
+ cron = frontmatter["cron"] || frontmatter["schedule"]
49
+ raise Error, "schedule #{name}: missing `cron:` frontmatter" if cron.nil?
50
+
51
+ new(name: name, cron: cron.to_s, queue: (frontmatter["queue"] || Silas.config.queue_name).to_s,
52
+ kind: :task, payload: body.strip)
53
+ end
54
+
55
+ def self.from_handler(name, klass)
56
+ unless klass.ancestors.include?(Schedule::Handler)
57
+ raise Error, "#{klass} (schedule #{name}) must inherit Silas::Schedule::Handler"
58
+ end
59
+ cron = klass.cron
60
+ raise Error, "schedule #{name}: handler must declare `cron` or `every`" if cron.nil?
61
+
62
+ new(name: name, cron: cron, queue: (klass.queue || Silas.config.queue_name).to_s,
63
+ kind: :handler, payload: klass)
64
+ end
65
+
66
+ # The only behavioural difference between the two forms.
67
+ def trigger!
68
+ case kind
69
+ when :task
70
+ Silas.agent.start(input: payload, metadata: { "trigger" => "schedule", "schedule" => name })
71
+ when :handler
72
+ payload.new(schedule: self).call
73
+ end
74
+ end
75
+
76
+ def recurring_key = "silas_schedule_#{name.tr('/', '_')}"
77
+
78
+ def recurring_entry
79
+ { "class" => "Silas::ScheduleJob", "args" => [ name ], "schedule" => cron, "queue" => queue }
80
+ end
81
+
82
+ # Base class for .rb handler schedules.
83
+ class Handler
84
+ class << self
85
+ def cron(expr = nil)
86
+ expr ? @schedule_expr = expr.to_s : @schedule_expr
87
+ end
88
+
89
+ def every(expr)
90
+ @schedule_expr = "every #{expr}"
91
+ end
92
+
93
+ def queue(name = nil)
94
+ name ? @queue = name.to_s : @queue
95
+ end
96
+ end
97
+
98
+ attr_reader :schedule
99
+
100
+ def initialize(schedule:)
101
+ @schedule = schedule
102
+ end
103
+
104
+ def call
105
+ raise NotImplementedError, "#{self.class}#call"
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,21 @@
1
+ module Silas
2
+ # A flat markdown playbook with a description: frontmatter key — the
3
+ # description is advertised to the model; the body loads on demand via the
4
+ # load_skill tool (eve's progressive-disclosure mechanism).
5
+ Skill = Data.define(:name, :description, :path) do
6
+ def self.parse(path)
7
+ content = File.read(path)
8
+ description =
9
+ if content =~ /\A---\s*\n(.*?)\n---\s*\n/m
10
+ Regexp.last_match(1)[/^description:\s*(.+)$/, 1].to_s.strip
11
+ else
12
+ ""
13
+ end
14
+ new(name: File.basename(path, ".md"), description: description, path: path.to_s)
15
+ end
16
+
17
+ def body
18
+ File.read(path).sub(/\A---\s*\n.*?\n---\s*\n/m, "")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,55 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "openssl"
4
+
5
+ module Silas
6
+ # Thin Slack Web API helper — no gem dependency. Posting (chat.postMessage),
7
+ # approval Block Kit, and inbound request-signature verification.
8
+ module Slack
9
+ module_function
10
+
11
+ POST_MESSAGE = "https://slack.com/api/chat.postMessage".freeze
12
+ REPLAY_WINDOW = 300 # seconds
13
+
14
+ def post_message(channel:, text:, blocks: nil, thread_ts: nil, token: Silas.config.slack_bot_token)
15
+ raise Error, "no Slack bot token configured (credentials.silas.slack.bot_token)" if token.blank?
16
+
17
+ body = { channel: channel, text: text }
18
+ body[:blocks] = blocks if blocks
19
+ body[:thread_ts] = thread_ts if thread_ts
20
+
21
+ res = Net::HTTP.post(URI(POST_MESSAGE), body.to_json,
22
+ "content-type" => "application/json; charset=utf-8",
23
+ "authorization" => "Bearer #{token}")
24
+ JSON.parse(res.body)
25
+ end
26
+
27
+ # Approve/Decline buttons; the button value is the invocation id, so the
28
+ # actions webhook maps a click straight to invocation.approve!/decline!.
29
+ def approval_blocks(invocation)
30
+ [
31
+ { "type" => "section",
32
+ "text" => { "type" => "mrkdwn",
33
+ "text" => "*Approval needed:* `#{invocation.tool_name}`\n```#{JSON.generate(invocation.arguments)}```" } },
34
+ { "type" => "actions", "block_id" => "silas_approval",
35
+ "elements" => [
36
+ { "type" => "button", "action_id" => "silas_approve", "style" => "primary",
37
+ "text" => { "type" => "plain_text", "text" => "Approve" }, "value" => invocation.id.to_s },
38
+ { "type" => "button", "action_id" => "silas_decline", "style" => "danger",
39
+ "text" => { "type" => "plain_text", "text" => "Decline" }, "value" => invocation.id.to_s }
40
+ ] }
41
+ ]
42
+ end
43
+
44
+ # Slack signs each request (v0 scheme) — HMAC-SHA256 over "v0:ts:body" plus a
45
+ # 5-minute replay window. Returns true only for a genuine, fresh request.
46
+ def verify_signature(signing_secret:, timestamp:, body:, signature:, now: Time.now.to_i)
47
+ return false if signing_secret.blank? || signature.blank? || timestamp.blank?
48
+ return false if (now - timestamp.to_i).abs > REPLAY_WINDOW
49
+
50
+ basestring = "v0:#{timestamp}:#{body}"
51
+ expected = "v0=" + OpenSSL::HMAC.hexdigest("SHA256", signing_secret, basestring)
52
+ ActiveSupport::SecurityUtils.secure_compare(expected, signature)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,98 @@
1
+ module Silas
2
+ # The idempotent body of one continuation step: at most one model call per
3
+ # (turn, index), replay feeds from rows, tool execution goes through the
4
+ # Ledger. Every path must be safe to run twice (crash replay) and safe to
5
+ # run in a fresh job (post-approval resume).
6
+ module StepRunner
7
+ module_function
8
+
9
+ # Returns :continue, :terminal, or :parked.
10
+ def call(turn, index)
11
+ step = Step.find_or_create_by!(turn: turn, index: index)
12
+
13
+ unless step.completed?
14
+ result = execute_model_call(turn, index)
15
+
16
+ # One transaction: the step's response, its terminal verdict, and the
17
+ # pending ledger rows commit together — or none of them do.
18
+ ApplicationRecord.transaction do
19
+ step.update!(
20
+ status: "completed",
21
+ response_blocks: result.blocks,
22
+ stop_reason: result.stop_reason,
23
+ terminal: result.terminal?,
24
+ model: turn_model(turn),
25
+ input_tokens: result.usage&.dig(:input_tokens),
26
+ output_tokens: result.usage&.dig(:output_tokens)
27
+ )
28
+ result.tool_calls.each do |tc|
29
+ ToolInvocation.create!(
30
+ step: step, turn: turn,
31
+ tool_call_id: tc.id, tool_name: tc.name, arguments: tc.arguments,
32
+ effect_mode: effect_mode_for(tc.name)
33
+ )
34
+ end
35
+ rescue ActiveRecord::RecordNotUnique
36
+ # A racing execution committed this step first; fall through to
37
+ # settle from the persisted rows.
38
+ end
39
+ step.reload
40
+ end
41
+
42
+ case Ledger.settle!(step, resolver: Silas.tool_resolver)
43
+ when :parked
44
+ park_turn!(turn, step)
45
+ :parked
46
+ else
47
+ step.terminal? ? :terminal : :continue
48
+ end
49
+ end
50
+
51
+ def execute_model_call(turn, index)
52
+ assert_definitions_unchanged!(turn)
53
+ engine = Silas.resolved_engine
54
+ context = {
55
+ turn: turn,
56
+ index: index,
57
+ system: turn.instructions_snapshot,
58
+ messages: MessageBuilder.call(turn, upto_index: index),
59
+ tools: Silas.tool_definitions,
60
+ model: turn_model(turn),
61
+ limits: { max_steps: Silas.agent.max_steps }
62
+ }
63
+ if (hook = Silas.config.around_model_call)
64
+ hook.call(context) { engine.execute_step(context) }
65
+ else
66
+ engine.execute_step(context)
67
+ end
68
+ end
69
+
70
+ # A deploy that changes tools/skills mid-turn must fail loudly, never
71
+ # resume into a different agent than the one that started the turn.
72
+ def assert_definitions_unchanged!(turn)
73
+ return if turn.definitions_digest.blank? || Silas.config.definitions_digest.nil?
74
+
75
+ live = Silas.config.definitions_digest.call.to_s
76
+ return if live == turn.definitions_digest
77
+
78
+ turn.finish!(:failed, reason: "definitions_changed")
79
+ raise NondeterminismError,
80
+ "Tool/skill definitions changed mid-turn (digest #{turn.definitions_digest[0, 12]}… → " \
81
+ "#{live[0, 12]}…). The turn was failed rather than resumed against a different agent."
82
+ end
83
+
84
+ def park_turn!(turn, step)
85
+ new_status = step.tool_invocations.reload.any?(&:in_doubt?) ? "in_doubt" : "waiting"
86
+ turn.update!(status: new_status)
87
+ end
88
+
89
+ def effect_mode_for(tool_name)
90
+ tool = Silas.tool_resolver.call(tool_name)
91
+ tool.respond_to?(:effect_mode) ? tool.effect_mode.to_s : "at_most_once"
92
+ end
93
+
94
+ def turn_model(_turn)
95
+ Silas.agent.model
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,41 @@
1
+ module Silas
2
+ # The engine-owned analog of StepRunner: it wraps one whole subprocess run in
3
+ # a single anchor Step and persists the durable result. Replay-aware and
4
+ # fail-closed — a resumed run whose subprocess got far enough to register a
5
+ # CLI session but did not finish is FAILED rather than re-spawned, because an
6
+ # engine-owned subprocess can't be replayed exactly-once (design risk #1).
7
+ module SubprocessRunner
8
+ module_function
9
+
10
+ # Returns :terminal or :failed.
11
+ def call(turn)
12
+ step = Step.find_or_create_by!(turn: turn, index: 0)
13
+ return :terminal if step.completed?
14
+
15
+ if turn.cli_session_id.present?
16
+ # A prior execution spawned a subprocess that never completed the step.
17
+ turn.finish!(:failed, reason: "agent_sdk_interrupted")
18
+ return :failed
19
+ end
20
+
21
+ result = Silas.resolved_engine.execute_step(engine_context(turn, step))
22
+ step.update!(
23
+ status: "completed", terminal: true,
24
+ response_blocks: result.blocks, stop_reason: result.stop_reason,
25
+ model: Silas.agent.model,
26
+ input_tokens: result.usage&.dig(:input_tokens),
27
+ output_tokens: result.usage&.dig(:output_tokens)
28
+ )
29
+ :terminal
30
+ end
31
+
32
+ def engine_context(turn, step)
33
+ { turn: turn, step: step, index: 0,
34
+ system: turn.instructions_snapshot,
35
+ messages: MessageBuilder.call(turn, upto_index: nil),
36
+ tools: Silas.tool_definitions,
37
+ model: Silas.agent.model,
38
+ limits: { max_steps: Silas.agent.max_steps } }
39
+ end
40
+ end
41
+ end