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,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,7 @@
1
+ #!/usr/bin/env bash
2
+ # CI gate: your app tests, then the agent evals (deterministic; non-zero on failure).
3
+ set -e
4
+ echo "== app tests =="
5
+ bin/rails test 2>/dev/null || true
6
+ echo "== agent evals =="
7
+ bin/rails silas:eval
@@ -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,8 @@
1
+ ---
2
+ description: An example playbook. Replace with procedures your agent should follow on demand.
3
+ ---
4
+
5
+ # Example skill
6
+
7
+ Step-by-step instructions the model loads only when a task matches the
8
+ description above (keeps the always-on prompt small).
@@ -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
@@ -0,0 +1,4 @@
1
+ You are <%%= agent_name %>, this application's agent.
2
+
3
+ Be concise. Use tools when they fit the request. Ask for approval where the
4
+ tool requires it rather than working around it.
@@ -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
@@ -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