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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/app/controllers/silas/channels/approvals_controller.rb +34 -0
- data/app/controllers/silas/channels/base_controller.rb +22 -0
- data/app/controllers/silas/channels/slack_controller.rb +58 -0
- data/app/controllers/silas/inbox/base_controller.rb +37 -0
- data/app/controllers/silas/inbox/invocations_controller.rb +44 -0
- data/app/controllers/silas/inbox/sessions_controller.rb +16 -0
- data/app/helpers/silas/inbox/trace_helper.rb +42 -0
- data/app/jobs/silas/agent_loop_job.rb +95 -0
- data/app/jobs/silas/channel_delivery_job.rb +62 -0
- data/app/jobs/silas/dead_job_rescuer_job.rb +31 -0
- data/app/jobs/silas/schedule_job.rb +22 -0
- data/app/mailboxes/silas/agent_mailbox.rb +35 -0
- data/app/mailers/silas/channel_mailer.rb +17 -0
- data/app/models/concerns/silas/inbox/broadcastable.rb +86 -0
- data/app/models/silas/application_record.rb +5 -0
- data/app/models/silas/session.rb +36 -0
- data/app/models/silas/step.rb +33 -0
- data/app/models/silas/tool_invocation.rb +78 -0
- data/app/models/silas/turn.rb +58 -0
- data/app/views/layouts/silas/inbox.html.erb +91 -0
- data/app/views/silas/channel_mailer/approval.text.erb +9 -0
- data/app/views/silas/channels/approvals/done.html.erb +2 -0
- data/app/views/silas/channels/approvals/error.html.erb +2 -0
- data/app/views/silas/channels/approvals/show.html.erb +9 -0
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +11 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +11 -0
- data/app/views/silas/inbox/sessions/_cost.html.erb +1 -0
- data/app/views/silas/inbox/sessions/index.html.erb +23 -0
- data/app/views/silas/inbox/sessions/show.html.erb +20 -0
- data/app/views/silas/inbox/steps/_step.html.erb +8 -0
- data/app/views/silas/inbox/turns/_header.html.erb +7 -0
- data/app/views/silas/inbox/turns/_turn.html.erb +8 -0
- data/config/routes.rb +19 -0
- data/db/migrate/20260714000001_create_silas_tables.rb +78 -0
- data/db/migrate/20260715000001_add_channel_outbound_markers.rb +8 -0
- data/db/migrate/20260715000002_add_agent_sdk_columns_to_turns.rb +9 -0
- data/db/migrate/20260715000003_add_parent_session_to_silas_sessions.rb +8 -0
- data/lib/generators/silas/install/install_generator.rb +81 -0
- data/lib/generators/silas/install/templates/agent.yml +9 -0
- data/lib/generators/silas/install/templates/bin_ci +7 -0
- data/lib/generators/silas/install/templates/channel_email.rb +18 -0
- data/lib/generators/silas/install/templates/channel_slack.rb +19 -0
- data/lib/generators/silas/install/templates/example_eval.rb +22 -0
- data/lib/generators/silas/install/templates/example_schedule.md +11 -0
- data/lib/generators/silas/install/templates/example_skill.md +8 -0
- data/lib/generators/silas/install/templates/example_tool.rb +12 -0
- data/lib/generators/silas/install/templates/initializer.rb +16 -0
- data/lib/generators/silas/install/templates/instructions.md +4 -0
- data/lib/silas/agent.rb +33 -0
- data/lib/silas/agent_scope.rb +6 -0
- data/lib/silas/agent_sdk/cli.rb +59 -0
- data/lib/silas/agent_sdk/stream_parser.rb +86 -0
- data/lib/silas/agent_sdk/version_guard.rb +26 -0
- data/lib/silas/budget.rb +42 -0
- data/lib/silas/channel.rb +69 -0
- data/lib/silas/configuration.rb +164 -0
- data/lib/silas/connection.rb +77 -0
- data/lib/silas/connections.rb +52 -0
- data/lib/silas/engine.rb +36 -0
- data/lib/silas/engines/agent_sdk.rb +75 -0
- data/lib/silas/engines/base.rb +30 -0
- data/lib/silas/engines/ruby_llm.rb +136 -0
- data/lib/silas/errors.rb +26 -0
- data/lib/silas/eval/assertions.rb +107 -0
- data/lib/silas/eval/driver.rb +47 -0
- data/lib/silas/eval/dsl.rb +55 -0
- data/lib/silas/eval/grader.rb +37 -0
- data/lib/silas/eval/result.rb +28 -0
- data/lib/silas/eval/runner.rb +38 -0
- data/lib/silas/eval/scripted_engine.rb +39 -0
- data/lib/silas/eval/transcript.rb +22 -0
- data/lib/silas/eval.rb +35 -0
- data/lib/silas/inbox/cost.rb +58 -0
- data/lib/silas/inbox.rb +23 -0
- data/lib/silas/instructions.rb +55 -0
- data/lib/silas/ledger.rb +178 -0
- data/lib/silas/mcp/client.rb +86 -0
- data/lib/silas/mcp/handler.rb +89 -0
- data/lib/silas/mcp/server.rb +134 -0
- data/lib/silas/message_builder.rb +63 -0
- data/lib/silas/nested_runner.rb +41 -0
- data/lib/silas/registry.rb +155 -0
- data/lib/silas/sandbox/docker.rb +67 -0
- data/lib/silas/sandbox/null.rb +17 -0
- data/lib/silas/sandbox.rb +15 -0
- data/lib/silas/schedule/compiler.rb +52 -0
- data/lib/silas/schedule.rb +109 -0
- data/lib/silas/skill.rb +21 -0
- data/lib/silas/slack.rb +55 -0
- data/lib/silas/step_runner.rb +98 -0
- data/lib/silas/subprocess_runner.rb +41 -0
- data/lib/silas/tool.rb +93 -0
- data/lib/silas/tools/delegate.rb +42 -0
- data/lib/silas/tools/load_skill.rb +25 -0
- data/lib/silas/tools/run_code.rb +21 -0
- data/lib/silas/version.rb +3 -0
- data/lib/silas.rb +144 -0
- data/lib/tasks/silas_eval.rake +20 -0
- data/lib/tasks/silas_schedules.rake +33 -0
- 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
|
data/lib/silas/skill.rb
ADDED
|
@@ -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
|
data/lib/silas/slack.rb
ADDED
|
@@ -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
|