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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -0
  3. data/README.md +55 -14
  4. data/app/controllers/silas/inbox/sessions_controller.rb +24 -0
  5. data/app/controllers/silas/inbox/turns_controller.rb +32 -0
  6. data/app/jobs/silas/agent_loop_job.rb +46 -43
  7. data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
  8. data/app/models/silas/memory.rb +51 -0
  9. data/app/models/silas/session.rb +5 -3
  10. data/app/models/silas/tool_invocation.rb +13 -1
  11. data/app/models/silas/turn.rb +1 -0
  12. data/app/views/layouts/silas/inbox.html.erb +14 -0
  13. data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
  14. data/app/views/silas/inbox/sessions/index.html.erb +20 -2
  15. data/app/views/silas/inbox/sessions/show.html.erb +11 -0
  16. data/app/views/silas/inbox/steps/_step.html.erb +6 -1
  17. data/app/views/silas/inbox/turns/_header.html.erb +7 -0
  18. data/config/routes.rb +6 -1
  19. data/db/migrate/20260721000001_create_silas_memories.rb +22 -0
  20. data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
  21. data/lib/generators/silas/install/install_generator.rb +33 -14
  22. data/lib/generators/silas/install/templates/bin_ci +2 -2
  23. data/lib/generators/silas/install/templates/initializer.rb +29 -5
  24. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
  25. data/lib/silas/chat.rb +45 -13
  26. data/lib/silas/configuration.rb +75 -24
  27. data/lib/silas/delta_buffer.rb +50 -0
  28. data/lib/silas/engine.rb +6 -0
  29. data/lib/silas/engines/base.rb +6 -8
  30. data/lib/silas/engines/ruby_llm.rb +15 -4
  31. data/lib/silas/errors.rb +3 -3
  32. data/lib/silas/eval/scripted_engine.rb +0 -2
  33. data/lib/silas/inbox/delta_broadcaster.rb +38 -0
  34. data/lib/silas/instructions.rb +13 -1
  35. data/lib/silas/ledger.rb +26 -10
  36. data/lib/silas/mcp/handler.rb +6 -5
  37. data/lib/silas/mcp/server.rb +9 -9
  38. data/lib/silas/nested_runner.rb +2 -2
  39. data/lib/silas/registry.rb +12 -2
  40. data/lib/silas/step_runner.rb +21 -6
  41. data/lib/silas/tool.rb +5 -0
  42. data/lib/silas/tools/handoff.rb +68 -0
  43. data/lib/silas/tools/recall.rb +18 -0
  44. data/lib/silas/tools/remember.rb +32 -0
  45. data/lib/silas/version.rb +1 -1
  46. data/lib/silas.rb +20 -9
  47. metadata +10 -6
  48. data/lib/silas/agent_sdk/cli.rb +0 -59
  49. data/lib/silas/agent_sdk/stream_parser.rb +0 -86
  50. data/lib/silas/agent_sdk/version_guard.rb +0 -26
  51. data/lib/silas/engines/agent_sdk.rb +0 -75
  52. 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