silas 0.1.6 → 0.2.0
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 +4 -4
- data/CHANGELOG.md +118 -0
- data/README.md +55 -14
- data/app/controllers/silas/inbox/sessions_controller.rb +24 -0
- data/app/controllers/silas/inbox/turns_controller.rb +32 -0
- data/app/jobs/silas/agent_loop_job.rb +46 -43
- data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
- data/app/models/silas/memory.rb +51 -0
- data/app/models/silas/session.rb +5 -3
- data/app/models/silas/tool_invocation.rb +13 -1
- data/app/models/silas/turn.rb +1 -0
- data/app/views/layouts/silas/inbox.html.erb +14 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
- data/app/views/silas/inbox/sessions/index.html.erb +20 -2
- data/app/views/silas/inbox/sessions/show.html.erb +11 -0
- data/app/views/silas/inbox/steps/_step.html.erb +6 -1
- data/app/views/silas/inbox/turns/_header.html.erb +7 -0
- data/config/routes.rb +6 -1
- data/db/migrate/20260721000001_create_silas_memories.rb +22 -0
- data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
- data/lib/generators/silas/install/install_generator.rb +33 -14
- data/lib/generators/silas/install/templates/bin_ci +2 -2
- data/lib/generators/silas/install/templates/initializer.rb +29 -5
- data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
- data/lib/silas/chat.rb +45 -13
- data/lib/silas/configuration.rb +75 -24
- data/lib/silas/delta_buffer.rb +50 -0
- data/lib/silas/engine.rb +6 -0
- data/lib/silas/engines/base.rb +6 -8
- data/lib/silas/engines/ruby_llm.rb +15 -4
- data/lib/silas/errors.rb +3 -3
- data/lib/silas/eval/scripted_engine.rb +0 -2
- data/lib/silas/inbox/delta_broadcaster.rb +38 -0
- data/lib/silas/instructions.rb +13 -1
- data/lib/silas/ledger.rb +26 -10
- data/lib/silas/mcp/handler.rb +6 -5
- data/lib/silas/mcp/server.rb +9 -9
- data/lib/silas/nested_runner.rb +2 -2
- data/lib/silas/registry.rb +12 -2
- data/lib/silas/step_runner.rb +21 -6
- data/lib/silas/tool.rb +5 -0
- data/lib/silas/tools/handoff.rb +68 -0
- data/lib/silas/tools/recall.rb +18 -0
- data/lib/silas/tools/remember.rb +32 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +20 -9
- metadata +10 -6
- data/lib/silas/agent_sdk/cli.rb +0 -59
- data/lib/silas/agent_sdk/stream_parser.rb +0 -86
- data/lib/silas/agent_sdk/version_guard.rb +0 -26
- data/lib/silas/engines/agent_sdk.rb +0 -75
- data/lib/silas/subprocess_runner.rb +0 -41
|
@@ -1,86 +0,0 @@
|
|
|
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
|
|
@@ -1,26 +0,0 @@
|
|
|
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
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
module Silas
|
|
2
|
-
module Engines
|
|
3
|
-
# The engine-owned adapter: one `claude -p` subprocess runs a whole agentic
|
|
4
|
-
# turn, calling back into Silas tools over an in-worker HTTP MCP endpoint
|
|
5
|
-
# whose tools/call goes THROUGH the Ledger (exactly-once within the run).
|
|
6
|
-
# The mirror image of :ruby_llm — Claude Code owns the loop; Silas hosts the
|
|
7
|
-
# tools and maps the NDJSON stream onto durable rows.
|
|
8
|
-
#
|
|
9
|
-
# v1 contract (honestly weaker than :ruby_llm): exactly-once WITHIN a run,
|
|
10
|
-
# approval :never tools only, and fail-closed on mid-subprocess worker kill.
|
|
11
|
-
class AgentSdk < Base
|
|
12
|
-
def self.loop_ownership = :engine
|
|
13
|
-
|
|
14
|
-
def execute_step(context, &on_event)
|
|
15
|
-
turn = context[:turn]
|
|
16
|
-
Silas::AgentSdk::VersionGuard.assert!(Silas.config.agent_sdk_claude_bin)
|
|
17
|
-
assert_api_key!
|
|
18
|
-
|
|
19
|
-
tools = allowed_tools(context[:tools])
|
|
20
|
-
server = Mcp::Server.start(turn: turn, step: context[:step], tools: tools, resolver: Silas.tool_resolver)
|
|
21
|
-
cli = nil
|
|
22
|
-
begin
|
|
23
|
-
server.await_ready!
|
|
24
|
-
cli = build_cli(context, server, tools)
|
|
25
|
-
parser = Silas::AgentSdk::StreamParser.new
|
|
26
|
-
cli.stream do |line|
|
|
27
|
-
parser.ingest(line) do |event|
|
|
28
|
-
persist_session_id!(turn, parser)
|
|
29
|
-
on_event&.call(event)
|
|
30
|
-
ActiveSupport::Notifications.instrument("silas.agent_sdk.event", turn_id: turn.id, type: event.type)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
parser.to_result
|
|
34
|
-
ensure
|
|
35
|
-
cli&.terminate
|
|
36
|
-
server.stop
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
private
|
|
41
|
-
|
|
42
|
-
def build_cli(context, server, tools)
|
|
43
|
-
Silas::AgentSdk::Cli.new(
|
|
44
|
-
bin: Silas.config.agent_sdk_claude_bin,
|
|
45
|
-
prompt: context[:turn].input,
|
|
46
|
-
system: context[:system],
|
|
47
|
-
model: Silas.config.agent_sdk_model || context[:model],
|
|
48
|
-
mcp_url: server.mcp_url,
|
|
49
|
-
allowed: tools.map { |d| "mcp__silas__#{d['name']}" }
|
|
50
|
-
)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# v1 excludes approval-gated tools entirely (both tools/list and
|
|
54
|
-
# --allowedTools) — an engine-owned subprocess can't park cheaply.
|
|
55
|
-
def allowed_tools(definitions)
|
|
56
|
-
kept = definitions.select { |d| Silas.tool_resolver.call(d["name"]).approval_policy == :never }
|
|
57
|
-
dropped = definitions.map { |d| d["name"] } - kept.map { |d| d["name"] }
|
|
58
|
-
Rails.logger&.info("silas: :agent_sdk excludes approval-gated tools #{dropped.inspect}") if dropped.any?
|
|
59
|
-
kept
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def persist_session_id!(turn, parser)
|
|
63
|
-
return unless turn.cli_session_id.nil? && parser.session_id.present?
|
|
64
|
-
|
|
65
|
-
turn.update_columns(cli_session_id: parser.session_id)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def assert_api_key!
|
|
69
|
-
return if ENV["ANTHROPIC_API_KEY"].present?
|
|
70
|
-
|
|
71
|
-
raise Silas::BootGuardError, ":agent_sdk uses --bare (API-key auth only); set ANTHROPIC_API_KEY"
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
@@ -1,41 +0,0 @@
|
|
|
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
|