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,78 @@
|
|
|
1
|
+
class CreateSilasTables < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :silas_sessions do |t|
|
|
4
|
+
t.string :agent_name, null: false, default: "agent"
|
|
5
|
+
t.string :status, null: false, default: "active" # active | archived
|
|
6
|
+
t.string :continuation_token
|
|
7
|
+
t.string :channel # channels seam (v-next)
|
|
8
|
+
# json (not jsonb): must work on SQLite and Postgres alike.
|
|
9
|
+
t.json :metadata, null: false, default: {}
|
|
10
|
+
t.json :loaded_skills, null: false, default: []
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
add_index :silas_sessions, :continuation_token, unique: true
|
|
14
|
+
|
|
15
|
+
create_table :silas_turns do |t|
|
|
16
|
+
t.references :session, null: false, index: true
|
|
17
|
+
t.integer :index, null: false
|
|
18
|
+
t.string :status, null: false, default: "queued"
|
|
19
|
+
# queued | running | waiting (parked) | in_doubt | completed | failed | canceled
|
|
20
|
+
t.text :input, null: false
|
|
21
|
+
t.text :instructions_snapshot # rendered ONCE at :prepare; immutable after set
|
|
22
|
+
t.string :definitions_digest # tool schemas + skill descriptions at turn start
|
|
23
|
+
t.string :failure_reason
|
|
24
|
+
t.string :job_id
|
|
25
|
+
t.integer :input_tokens, null: false, default: 0
|
|
26
|
+
t.integer :output_tokens, null: false, default: 0
|
|
27
|
+
t.integer :cost_microcents, null: false, default: 0
|
|
28
|
+
t.datetime :started_at
|
|
29
|
+
t.datetime :finished_at
|
|
30
|
+
t.timestamps
|
|
31
|
+
end
|
|
32
|
+
add_index :silas_turns, [ :session_id, :index ], unique: true
|
|
33
|
+
# Single-active-turn invariant: at most one non-final turn per session.
|
|
34
|
+
add_index :silas_turns, :session_id, unique: true,
|
|
35
|
+
where: "status IN ('queued','running','waiting','in_doubt')",
|
|
36
|
+
name: "index_silas_turns_single_active"
|
|
37
|
+
|
|
38
|
+
create_table :silas_steps do |t|
|
|
39
|
+
t.references :turn, null: false, index: true
|
|
40
|
+
t.integer :index, null: false
|
|
41
|
+
t.string :status, null: false, default: "started" # started | completed
|
|
42
|
+
t.string :model
|
|
43
|
+
t.json :response_blocks
|
|
44
|
+
t.string :stop_reason
|
|
45
|
+
# Write-once after completion; THE loop-control column. Resumed
|
|
46
|
+
# continuations must re-derive the identical step sequence from it.
|
|
47
|
+
t.boolean :terminal
|
|
48
|
+
t.integer :input_tokens
|
|
49
|
+
t.integer :output_tokens
|
|
50
|
+
t.timestamps
|
|
51
|
+
end
|
|
52
|
+
# At most one persisted model response per (turn, index).
|
|
53
|
+
add_index :silas_steps, [ :turn_id, :index ], unique: true
|
|
54
|
+
|
|
55
|
+
create_table :silas_tool_invocations do |t|
|
|
56
|
+
t.references :step, null: false, index: true
|
|
57
|
+
t.references :turn, null: false # denormalized: :once approval queries + rescue sweeps
|
|
58
|
+
t.string :tool_call_id, null: false
|
|
59
|
+
t.string :tool_name, null: false
|
|
60
|
+
t.json :arguments, null: false, default: {}
|
|
61
|
+
t.string :status, null: false, default: "pending"
|
|
62
|
+
# pending | started | completed | failed | in_doubt
|
|
63
|
+
# Snapshotted from the tool class at creation, so editing a tool mid-park
|
|
64
|
+
# cannot change execution or in-doubt semantics for an existing invocation.
|
|
65
|
+
t.string :effect_mode, null: false # transactional | at_most_once | idempotent
|
|
66
|
+
t.json :result
|
|
67
|
+
t.text :error
|
|
68
|
+
t.string :approval_state # NULL | required | approved | declined | expired
|
|
69
|
+
t.datetime :approval_expires_at
|
|
70
|
+
t.string :approved_by
|
|
71
|
+
t.text :decline_reason
|
|
72
|
+
t.timestamps
|
|
73
|
+
end
|
|
74
|
+
# THE exactly-once key (existence checks are an optimization).
|
|
75
|
+
add_index :silas_tool_invocations, [ :step_id, :tool_call_id ], unique: true
|
|
76
|
+
add_index :silas_tool_invocations, [ :turn_id, :status ]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class AddChannelOutboundMarkers < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
# Outbound delivery idempotency keys (CAS-claimed by ChannelDeliveryJob):
|
|
4
|
+
# an approval is pinged once, a turn's answer is delivered once.
|
|
5
|
+
add_column :silas_tool_invocations, :notified_at, :datetime
|
|
6
|
+
add_column :silas_turns, :answered_at, :datetime
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class AddAgentSdkColumnsToTurns < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
# The Claude Code CLI session id (captured from the system:init event) — the
|
|
4
|
+
# in-doubt marker for fail-closed resume, and the future --resume key.
|
|
5
|
+
add_column :silas_turns, :cli_session_id, :string
|
|
6
|
+
# Per-turn bearer token for the in-worker MCP endpoint.
|
|
7
|
+
add_column :silas_turns, :mcp_token, :string
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class AddParentSessionToSilasSessions < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
# Subagent lineage (observability only, not correctness-load-bearing): a
|
|
4
|
+
# delegated session records the parent that spawned it.
|
|
5
|
+
add_column :silas_sessions, :parent_session_id, :bigint
|
|
6
|
+
add_index :silas_sessions, :parent_session_id
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module Silas
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
def create_initializer
|
|
9
|
+
template "initializer.rb", "config/initializers/silas.rb"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create_agent_directory
|
|
13
|
+
template "instructions.md", "app/agent/instructions.md"
|
|
14
|
+
template "agent.yml", "app/agent/agent.yml"
|
|
15
|
+
template "example_tool.rb", "app/agent/tools/example_tool.rb"
|
|
16
|
+
template "example_skill.md", "app/agent/skills/example.md"
|
|
17
|
+
template "example_schedule.md", "app/agent/schedules/example.md"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Channels are opt-in: scaffold the two reference bindings + mount the
|
|
21
|
+
# engine that serves their webhooks. Delete a channel file to disable it.
|
|
22
|
+
def create_channels
|
|
23
|
+
template "channel_slack.rb", "app/agent/channels/slack.rb"
|
|
24
|
+
template "channel_email.rb", "app/agent/channels/email.rb"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def mount_engine
|
|
28
|
+
route 'mount Silas::Engine => "/silas"'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Evals as a deploy gate: an example scenario + a bin/ci wrapper.
|
|
32
|
+
def create_eval_gate
|
|
33
|
+
template "example_eval.rb", "test/agent_evals/example_eval.rb"
|
|
34
|
+
copy_file "bin_ci", "bin/ci"
|
|
35
|
+
chmod "bin/ci", 0o755
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def add_rescuer_recurring_task
|
|
39
|
+
recurring = "config/recurring.yml"
|
|
40
|
+
entry = <<~YAML
|
|
41
|
+
|
|
42
|
+
# Silas: retries jobs failed by dead-worker reaping/pruning and sweeps
|
|
43
|
+
# expired approvals. Part of the durability contract — do not remove.
|
|
44
|
+
silas_dead_job_rescuer:
|
|
45
|
+
class: Silas::DeadJobRescuerJob
|
|
46
|
+
queue: default
|
|
47
|
+
schedule: every 30 seconds
|
|
48
|
+
YAML
|
|
49
|
+
|
|
50
|
+
if File.exist?(File.expand_path(recurring, destination_root))
|
|
51
|
+
append_to_file recurring, entry.indent(2)
|
|
52
|
+
else
|
|
53
|
+
create_file recurring, "production:#{entry.indent(2)}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def install_migrations
|
|
58
|
+
rake "silas:install:migrations"
|
|
59
|
+
rescue StandardError
|
|
60
|
+
say "Run `bin/rails silas:install:migrations db:migrate` to install Silas's tables.", :yellow
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def show_next_steps
|
|
64
|
+
say <<~MSG, :green
|
|
65
|
+
|
|
66
|
+
Silas installed. Next:
|
|
67
|
+
1. bin/rails db:migrate
|
|
68
|
+
2. Edit app/agent/instructions.md (your agent's persona)
|
|
69
|
+
3. Write tools in app/agent/tools/ (keyword signature = schema)
|
|
70
|
+
4. Silas.agent.start(input: "hello")
|
|
71
|
+
5. Schedules: edit app/agent/schedules/*, then `bin/rails silas:schedules`
|
|
72
|
+
6. Channels (optional): set credentials.silas.slack.{signing_secret,bot_token}
|
|
73
|
+
for Slack; route inbound mail to Silas::AgentMailbox for email.
|
|
74
|
+
Delete app/agent/channels/{slack,email}.rb to disable.
|
|
75
|
+
7. Inbox: live at /silas/inbox once you set config.inbox_auth
|
|
76
|
+
(deny-by-default until you do).
|
|
77
|
+
MSG
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Data-only agent config. Model defaults to Silas.config.default_model.
|
|
2
|
+
# model: claude-sonnet-5
|
|
3
|
+
description: This application's agent.
|
|
4
|
+
limits:
|
|
5
|
+
max_steps: 25 # model calls per turn
|
|
6
|
+
# max_input_tokens: 200000 # cumulative input tokens per turn
|
|
7
|
+
# max_cost: 1.00 # dollars per turn
|
|
8
|
+
# timeout: 300 # wall-clock seconds per turn
|
|
9
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Email channel: outbound delivery for sessions started from email. Inbound
|
|
2
|
+
# routing is via Action Mailbox (route mail to Silas::AgentMailbox in your
|
|
3
|
+
# app/mailboxes/application_mailbox.rb). Delete this file to disable email.
|
|
4
|
+
class Agent::Channels::Email < Silas::Channel
|
|
5
|
+
def deliver_answer(session:, text:)
|
|
6
|
+
email = session.metadata["email"] || {}
|
|
7
|
+
Silas::ChannelMailer.answer(
|
|
8
|
+
to: email["from"], subject: "Re: #{email['subject']}", text: text
|
|
9
|
+
).deliver_later
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def deliver_approval(session:, invocation:)
|
|
13
|
+
email = session.metadata["email"] || {}
|
|
14
|
+
Silas::ChannelMailer.approval(
|
|
15
|
+
to: email["from"], subject: "Approval needed: #{invocation.tool_name}", invocation: invocation
|
|
16
|
+
).deliver_later
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Slack channel: outbound delivery for sessions started from Slack. Inbound and
|
|
2
|
+
# approval-button handling live in Silas's mounted engine (Silas::Engine).
|
|
3
|
+
# Requires credentials.silas.slack.{signing_secret,bot_token}. Delete this file
|
|
4
|
+
# to disable the Slack channel.
|
|
5
|
+
class Agent::Channels::Slack < Silas::Channel
|
|
6
|
+
def deliver_answer(session:, text:)
|
|
7
|
+
slack = session.metadata["slack"] || {}
|
|
8
|
+
Silas::Slack.post_message(channel: slack["channel"], thread_ts: slack["thread_ts"], text: text)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def deliver_approval(session:, invocation:)
|
|
12
|
+
slack = session.metadata["slack"] || {}
|
|
13
|
+
Silas::Slack.post_message(
|
|
14
|
+
channel: slack["channel"], thread_ts: slack["thread_ts"],
|
|
15
|
+
text: "Approval needed: #{invocation.tool_name}",
|
|
16
|
+
blocks: Silas::Slack.approval_blocks(invocation)
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Agent evals: fixtures are scenarios, assertions run against the durable
|
|
2
|
+
# transcript. Run them as a deploy gate with `bin/rails silas:eval` (or bin/ci).
|
|
3
|
+
# You script the model's DECISIONS; the real Ledger runs your real tools.
|
|
4
|
+
Silas::Eval.scenario "example: the agent uses the example tool" do
|
|
5
|
+
input "Please echo 'hello'."
|
|
6
|
+
on_step 0, call: { name: "example_tool", arguments: { message: "hello" } }
|
|
7
|
+
on_step 1, text: "Done — echoed 'hello'."
|
|
8
|
+
|
|
9
|
+
expect do
|
|
10
|
+
assert_tool_called "example_tool"
|
|
11
|
+
assert_tool_arg "example_tool", :message, "hello"
|
|
12
|
+
assert_final_matches(/echoed/i)
|
|
13
|
+
assert_turn_completed
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Opt-in, offline-skippable LLM-graded check (runs against the real engine):
|
|
18
|
+
# Silas::Eval.scenario "tone is professional", tags: %i[rubric] do
|
|
19
|
+
# mode :real
|
|
20
|
+
# input "My order never arrived and I'm furious."
|
|
21
|
+
# expect { assert_rubric "The reply is empathetic and promises no amount a tool didn't return." }
|
|
22
|
+
# end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%# Task-mode schedule: this markdown body becomes the turn input on each tick. %>
|
|
2
|
+
---
|
|
3
|
+
cron: "0 9 * * *"
|
|
4
|
+
# queue: default
|
|
5
|
+
---
|
|
6
|
+
Summarize yesterday's activity and post the digest.
|
|
7
|
+
|
|
8
|
+
# INERT until you run `bin/rails silas:schedules` (which compiles schedules into
|
|
9
|
+
# config/recurring.yml — cron that fires real work stays a reviewable git diff).
|
|
10
|
+
# For programmatic control, write app/agent/schedules/<name>.rb subclassing
|
|
11
|
+
# Silas::Schedule::Handler with `cron`/`every` and a `#call` method instead.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Tool identity is the filename: this is the "example_tool" tool.
|
|
2
|
+
# The keyword signature of #call IS the schema the model sees.
|
|
3
|
+
class Agent::Tools::ExampleTool < Silas::Tool
|
|
4
|
+
description "Echo a message back (replace me with a real capability)."
|
|
5
|
+
# approval :always # park for human approval before running
|
|
6
|
+
# transactional! # DB-only side effects -> exactly-once
|
|
7
|
+
at_most_once! # default: external effects park when in doubt
|
|
8
|
+
|
|
9
|
+
def call(message:)
|
|
10
|
+
{ echo: message }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Silas.configure do |config|
|
|
2
|
+
# Inference engine: :ruby_llm (API key, any provider RubyLLM supports), or
|
|
3
|
+
# :agent_sdk (a `claude -p` subprocess; API-key auth only). See silas/README.
|
|
4
|
+
config.engine = :ruby_llm
|
|
5
|
+
|
|
6
|
+
config.default_model = "claude-sonnet-5"
|
|
7
|
+
|
|
8
|
+
# Parked approvals expire after this long (the turn fails as approval_expired).
|
|
9
|
+
# config.approval_ttl = 7.days
|
|
10
|
+
|
|
11
|
+
# Hard cap on model calls per turn (agent.yml limits.max_steps overrides).
|
|
12
|
+
# config.max_steps = 25
|
|
13
|
+
|
|
14
|
+
# Wrap every model call — e.g. ruby_llm-resilience:
|
|
15
|
+
# config.around_model_call = ->(ctx, &call) { RubyLLM::Resilience.chain(:anthropic) { call.() } }
|
|
16
|
+
end
|
data/lib/silas/agent.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Silas
|
|
4
|
+
# The parsed agent definition: app/agent/agent.yml (data-only config, eve's
|
|
5
|
+
# keys) over Silas.config defaults.
|
|
6
|
+
class Agent
|
|
7
|
+
def self.load(root: Rails.root, dir: nil)
|
|
8
|
+
dir ||= root.join("app/agent")
|
|
9
|
+
path = Pathname(dir).join("agent.yml")
|
|
10
|
+
new(path.exist? ? YAML.safe_load(path.read) || {} : {})
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(attrs = {})
|
|
14
|
+
@attrs = attrs
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def model = @attrs["model"] || Silas.config.default_model
|
|
18
|
+
def description = @attrs["description"].to_s
|
|
19
|
+
def limits = @attrs["limits"] || {}
|
|
20
|
+
def max_steps = limits["max_steps"] || Silas.config.max_steps
|
|
21
|
+
def max_input_tokens = limits["max_input_tokens"] # cumulative input tokens per turn
|
|
22
|
+
def max_cost = limits["max_cost"] # dollars per turn
|
|
23
|
+
def timeout = limits["timeout"] # wall-clock seconds per turn
|
|
24
|
+
|
|
25
|
+
# Start a session with its first turn. channel/continuation_token let a
|
|
26
|
+
# channel bind the session to an external thread (backward-compatible).
|
|
27
|
+
def start(input:, metadata: {}, channel: nil, continuation_token: nil)
|
|
28
|
+
session = Session.create!(metadata: metadata, channel: channel, continuation_token: continuation_token)
|
|
29
|
+
session.continue(input: input)
|
|
30
|
+
session
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# A subagent's capability bundle — its own tools, skills, config, and digest,
|
|
3
|
+
# built by the Registry from app/agent/subagents/<name>/. The nested run swaps
|
|
4
|
+
# these in as the active globals for the duration of the delegation.
|
|
5
|
+
AgentScope = Data.define(:name, :dir, :agent, :resolver, :definitions, :digest, :skills)
|
|
6
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Silas
|
|
4
|
+
module AgentSdk
|
|
5
|
+
# Builds the verified claude -p argv and manages the subprocess. --bare is
|
|
6
|
+
# the API-key-only auth guard (never subscription OAuth); alwaysLoad + a
|
|
7
|
+
# non-zero MCP_TIMEOUT are BOTH mandatory or the single-shot -p turn races
|
|
8
|
+
# ahead of the MCP handshake and never sees the tools (spike trap #1).
|
|
9
|
+
class Cli
|
|
10
|
+
def initialize(bin:, prompt:, system:, model:, mcp_url:, allowed:, resume_session_id: nil)
|
|
11
|
+
@bin = bin
|
|
12
|
+
@prompt = prompt
|
|
13
|
+
@system = system
|
|
14
|
+
@model = model
|
|
15
|
+
@mcp_url = mcp_url
|
|
16
|
+
@allowed = allowed
|
|
17
|
+
@resume_session_id = resume_session_id
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Spawns claude, yields each stdout line, returns the exit status integer.
|
|
21
|
+
def stream
|
|
22
|
+
@io = IO.popen(env, argv, err: %i[child out], pgroup: true)
|
|
23
|
+
@pid = @io.pid
|
|
24
|
+
@io.each_line { |line| yield line }
|
|
25
|
+
@io.close
|
|
26
|
+
$?.exitstatus
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def terminate
|
|
30
|
+
return unless @pid
|
|
31
|
+
|
|
32
|
+
Process.kill("-TERM", @pid)
|
|
33
|
+
Process.kill("-KILL", @pid)
|
|
34
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
35
|
+
# already gone
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def argv
|
|
39
|
+
args = [ @bin, "-p", @prompt,
|
|
40
|
+
"--output-format", "stream-json", "--verbose", "--bare",
|
|
41
|
+
"--mcp-config", mcp_config_json,
|
|
42
|
+
"--allowedTools", @allowed.join(","),
|
|
43
|
+
"--model", @model ]
|
|
44
|
+
args += [ "--append-system-prompt", @system ] if @system.present?
|
|
45
|
+
args += [ "--resume", @resume_session_id ] if @resume_session_id.present?
|
|
46
|
+
args
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def mcp_config_json
|
|
50
|
+
JSON.generate("mcpServers" => { "silas" => { "type" => "http", "url" => @mcp_url, "alwaysLoad" => true } })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def env
|
|
54
|
+
{ "ANTHROPIC_API_KEY" => ENV["ANTHROPIC_API_KEY"],
|
|
55
|
+
"MCP_TIMEOUT" => Silas.config.agent_sdk_mcp_timeout_ms.to_s }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Silas
|
|
4
|
+
module AgentSdk
|
|
5
|
+
# Tolerant NDJSON parser for `claude -p --output-format stream-json`. The
|
|
6
|
+
# event schema is under-documented and shifts between versions, so unknown
|
|
7
|
+
# lines/types must never raise. Yields Silas::Event objects; accumulates the
|
|
8
|
+
# terminal Result (final text, tool_use blocks, usage, session_id).
|
|
9
|
+
class StreamParser
|
|
10
|
+
attr_reader :session_id, :final_text, :usage, :stop_reason, :blocks, :tool_calls
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@blocks = []
|
|
14
|
+
@tool_calls = []
|
|
15
|
+
@final_text = nil
|
|
16
|
+
@usage = nil
|
|
17
|
+
@session_id = nil
|
|
18
|
+
@stop_reason = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ingest(line)
|
|
22
|
+
line = line.to_s.strip
|
|
23
|
+
return if line.empty?
|
|
24
|
+
|
|
25
|
+
event = JSON.parse(line)
|
|
26
|
+
@session_id ||= event["session_id"] if event["session_id"]
|
|
27
|
+
|
|
28
|
+
case event["type"]
|
|
29
|
+
when "system"
|
|
30
|
+
@session_id ||= event["session_id"]
|
|
31
|
+
yield Event.new(type: :"system.#{event['subtype']}", payload: symbolize(event))
|
|
32
|
+
when "assistant"
|
|
33
|
+
ingest_assistant(event) { |e| yield e }
|
|
34
|
+
when "user"
|
|
35
|
+
yield Event.new(type: :tool_result, payload: symbolize(event))
|
|
36
|
+
when "result"
|
|
37
|
+
@final_text = event["result"]
|
|
38
|
+
@stop_reason = event["subtype"]
|
|
39
|
+
@usage = extract_usage(event)
|
|
40
|
+
# The result text usually repeats the last assistant text — only add a
|
|
41
|
+
# block if it isn't already captured.
|
|
42
|
+
if @final_text && @blocks.none? { |b| b["type"] == "text" && b["text"] == @final_text }
|
|
43
|
+
@blocks << { "type" => "text", "text" => @final_text }
|
|
44
|
+
end
|
|
45
|
+
yield Event.new(type: :result, payload: symbolize(event))
|
|
46
|
+
else
|
|
47
|
+
yield Event.new(type: :unknown, payload: symbolize(event))
|
|
48
|
+
end
|
|
49
|
+
rescue JSON::ParserError
|
|
50
|
+
# A non-JSON line (banner, warning) — ignore.
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# tool_calls stays [] on the Result: in the engine-owned path Claude Code
|
|
54
|
+
# already executed the tools (through our MCP endpoint), so the framework
|
|
55
|
+
# loop must not try to run them again.
|
|
56
|
+
def to_result
|
|
57
|
+
Engines::Result.new(blocks: @blocks, tool_calls: [], stop_reason: @stop_reason || "end_turn", usage: @usage)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def ingest_assistant(event)
|
|
63
|
+
Array(event.dig("message", "content")).each do |block|
|
|
64
|
+
case block["type"]
|
|
65
|
+
when "text"
|
|
66
|
+
@blocks << { "type" => "text", "text" => block["text"] }
|
|
67
|
+
yield Event.new(type: :text, payload: { text: block["text"] })
|
|
68
|
+
when "tool_use"
|
|
69
|
+
@tool_calls << block["id"]
|
|
70
|
+
@blocks << { "type" => "tool_call", "id" => block["id"], "name" => block["name"], "arguments" => block["input"] }
|
|
71
|
+
yield Event.new(type: :tool_call, payload: symbolize(block))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def extract_usage(event)
|
|
77
|
+
u = event["usage"] || {}
|
|
78
|
+
{ input_tokens: u["input_tokens"], output_tokens: u["output_tokens"], cost_usd: event["total_cost_usd"] }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def symbolize(hash)
|
|
82
|
+
hash.transform_keys(&:to_sym)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module AgentSdk
|
|
3
|
+
# The adapter wraps an external CLI whose JSON stream can shift between
|
|
4
|
+
# versions, so pin a tested range and fail loudly outside it.
|
|
5
|
+
module VersionGuard
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def assert!(bin, requirement: Silas.config.agent_sdk_cli_version_range)
|
|
9
|
+
version = detect(bin)
|
|
10
|
+
raise Silas::Error, "could not determine `#{bin}` version (is the Claude CLI installed?)" if version.nil?
|
|
11
|
+
|
|
12
|
+
unless Gem::Requirement.new(requirement.split(",").map(&:strip)).satisfied_by?(Gem::Version.new(version))
|
|
13
|
+
raise Silas::Error, "claude CLI #{version} is outside the tested range (#{requirement}); pin or upgrade"
|
|
14
|
+
end
|
|
15
|
+
version
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def detect(bin)
|
|
19
|
+
out = `#{bin} --version 2>/dev/null`
|
|
20
|
+
out[/\d+\.\d+\.\d+/]
|
|
21
|
+
rescue StandardError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/silas/budget.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# Per-turn budget caps beyond max_steps: cumulative input tokens, cost, and
|
|
3
|
+
# wall-clock. Checked between steps in the framework-owned loop (never inside a
|
|
4
|
+
# continuation step), so a breach fails the turn cleanly with a limit reason.
|
|
5
|
+
#
|
|
6
|
+
# Token/cost checks are deterministic (persisted step data). The timeout check
|
|
7
|
+
# reads the wall clock — benign non-determinism: a cap firing later on resume
|
|
8
|
+
# is correct (the turn genuinely ran too long across the crash).
|
|
9
|
+
module Budget
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Returns a failure reason string if a cap is exceeded, else nil.
|
|
13
|
+
def exceeded_reason(turn, agent: Silas.agent)
|
|
14
|
+
return "max_input_tokens" if over_tokens?(turn, agent)
|
|
15
|
+
return "max_cost" if over_cost?(turn, agent)
|
|
16
|
+
return "timeout" if over_time?(turn, agent)
|
|
17
|
+
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def over_tokens?(turn, agent)
|
|
22
|
+
limit = agent.max_input_tokens or return false
|
|
23
|
+
|
|
24
|
+
Silas::Step.where(turn_id: turn.id).sum(:input_tokens) > limit
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def over_cost?(turn, agent)
|
|
28
|
+
limit = agent.max_cost or return false
|
|
29
|
+
|
|
30
|
+
spent = Silas::Inbox::Cost.for_turn(turn)
|
|
31
|
+
# Only enforce on priced tokens; unpriced models can't be cost-capped.
|
|
32
|
+
spent[:microcents] > (limit.to_f * 1_000_000)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def over_time?(turn, agent)
|
|
36
|
+
limit = agent.timeout or return false
|
|
37
|
+
return false unless turn.started_at
|
|
38
|
+
|
|
39
|
+
(Time.current - turn.started_at) > limit
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# A channel binds an external surface (email, Slack, HTTP) to the durable loop.
|
|
3
|
+
# Identity is the filename (app/agent/channels/slack.rb -> Agent::Channels::Slack).
|
|
4
|
+
#
|
|
5
|
+
# Inbound is pure trigger reuse: dispatch maps an external thread to a Session
|
|
6
|
+
# via silas_sessions.channel + continuation_token, then calls the UNCHANGED
|
|
7
|
+
# public API (new thread -> Silas.agent.start; reply -> session.continue).
|
|
8
|
+
# Outbound (deliver the agent's answer / an approval request) is a subclass
|
|
9
|
+
# responsibility, invoked off the loop by ChannelDeliveryJob — so the loop's
|
|
10
|
+
# determinism and the ledger's exactly-once are never touched.
|
|
11
|
+
class Channel
|
|
12
|
+
TOKEN_PURPOSE = "silas/channel".freeze
|
|
13
|
+
|
|
14
|
+
def self.channel_name = name.demodulize.underscore
|
|
15
|
+
|
|
16
|
+
# Stable external-thread key, namespaced by channel so two channels can't collide.
|
|
17
|
+
def self.namespaced(thread_key) = "#{channel_name}:#{thread_key}"
|
|
18
|
+
|
|
19
|
+
# The single inbound entry point for every transport.
|
|
20
|
+
def self.dispatch(thread_key:, input:, metadata: {})
|
|
21
|
+
token = namespaced(thread_key)
|
|
22
|
+
if (session = Silas::Session.find_by(continuation_token: token))
|
|
23
|
+
session.continue(input: input)
|
|
24
|
+
session
|
|
25
|
+
else
|
|
26
|
+
Silas.agent.start(input: input, metadata: metadata,
|
|
27
|
+
channel: channel_name, continuation_token: token)
|
|
28
|
+
end
|
|
29
|
+
rescue ActiveRecord::RecordNotUnique
|
|
30
|
+
# Concurrent first-inbound race: the other request created the session;
|
|
31
|
+
# treat this message as a continue.
|
|
32
|
+
session = Silas::Session.find_by!(continuation_token: namespaced(thread_key))
|
|
33
|
+
session.continue(input: input)
|
|
34
|
+
session
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolve the channel instance that owns a session (for outbound delivery).
|
|
38
|
+
def self.for_session(session)
|
|
39
|
+
return nil if session.channel.blank?
|
|
40
|
+
|
|
41
|
+
klass = Silas.config.channel_resolver&.call(session.channel)
|
|
42
|
+
klass&.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Signed token for email approve/decline links (no session state leaks into the URL).
|
|
46
|
+
def self.token_for(invocation, action)
|
|
47
|
+
verifier.generate({ "id" => invocation.id, "action" => action.to_s }, expires_in: Silas.config.approval_ttl)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.verify_token(token)
|
|
51
|
+
verifier.verify(token)
|
|
52
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveSupport::MessageEncryptor::InvalidMessage
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.verifier
|
|
57
|
+
Rails.application.message_verifier(TOKEN_PURPOSE)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# --- outbound interface (subclasses implement) ---
|
|
61
|
+
def deliver_answer(session:, text:)
|
|
62
|
+
raise NotImplementedError, "#{self.class}#deliver_answer"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def deliver_approval(session:, invocation:)
|
|
66
|
+
raise NotImplementedError, "#{self.class}#deliver_approval"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|