spurline-docs 0.3.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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/lib/spurline/adapters/base.rb +17 -0
  3. data/lib/spurline/adapters/claude.rb +208 -0
  4. data/lib/spurline/adapters/open_ai.rb +213 -0
  5. data/lib/spurline/adapters/registry.rb +33 -0
  6. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  7. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  8. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  9. data/lib/spurline/agent.rb +433 -0
  10. data/lib/spurline/audit/log.rb +156 -0
  11. data/lib/spurline/audit/secret_filter.rb +121 -0
  12. data/lib/spurline/base.rb +130 -0
  13. data/lib/spurline/cartographer/analyzer.rb +71 -0
  14. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  15. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  16. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  17. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  18. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  19. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  20. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  21. data/lib/spurline/cartographer/runner.rb +88 -0
  22. data/lib/spurline/cartographer.rb +6 -0
  23. data/lib/spurline/channels/base.rb +41 -0
  24. data/lib/spurline/channels/event.rb +136 -0
  25. data/lib/spurline/channels/github.rb +205 -0
  26. data/lib/spurline/channels/router.rb +103 -0
  27. data/lib/spurline/cli/check.rb +88 -0
  28. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  29. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  30. data/lib/spurline/cli/checks/base.rb +35 -0
  31. data/lib/spurline/cli/checks/credentials.rb +43 -0
  32. data/lib/spurline/cli/checks/permissions.rb +22 -0
  33. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  34. data/lib/spurline/cli/checks/session_store.rb +97 -0
  35. data/lib/spurline/cli/console.rb +73 -0
  36. data/lib/spurline/cli/credentials.rb +181 -0
  37. data/lib/spurline/cli/generators/agent.rb +123 -0
  38. data/lib/spurline/cli/generators/migration.rb +62 -0
  39. data/lib/spurline/cli/generators/project.rb +331 -0
  40. data/lib/spurline/cli/generators/tool.rb +98 -0
  41. data/lib/spurline/cli/router.rb +121 -0
  42. data/lib/spurline/configuration.rb +23 -0
  43. data/lib/spurline/dsl/guardrails.rb +108 -0
  44. data/lib/spurline/dsl/hooks.rb +51 -0
  45. data/lib/spurline/dsl/memory.rb +39 -0
  46. data/lib/spurline/dsl/model.rb +23 -0
  47. data/lib/spurline/dsl/persona.rb +74 -0
  48. data/lib/spurline/dsl/suspend_until.rb +53 -0
  49. data/lib/spurline/dsl/tools.rb +176 -0
  50. data/lib/spurline/errors.rb +109 -0
  51. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  52. data/lib/spurline/lifecycle/runner.rb +456 -0
  53. data/lib/spurline/lifecycle/states.rb +47 -0
  54. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  55. data/lib/spurline/memory/context_assembler.rb +100 -0
  56. data/lib/spurline/memory/embedder/base.rb +17 -0
  57. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  58. data/lib/spurline/memory/episode.rb +56 -0
  59. data/lib/spurline/memory/episodic_store.rb +147 -0
  60. data/lib/spurline/memory/long_term/base.rb +22 -0
  61. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  62. data/lib/spurline/memory/manager.rb +147 -0
  63. data/lib/spurline/memory/short_term.rb +57 -0
  64. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  65. data/lib/spurline/orchestration/judge.rb +109 -0
  66. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  67. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  68. data/lib/spurline/orchestration/ledger.rb +339 -0
  69. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  70. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  71. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  72. data/lib/spurline/persona/base.rb +42 -0
  73. data/lib/spurline/persona/registry.rb +42 -0
  74. data/lib/spurline/secrets/resolver.rb +65 -0
  75. data/lib/spurline/secrets/vault.rb +42 -0
  76. data/lib/spurline/security/content.rb +76 -0
  77. data/lib/spurline/security/context_pipeline.rb +58 -0
  78. data/lib/spurline/security/gates/base.rb +36 -0
  79. data/lib/spurline/security/gates/operator_config.rb +22 -0
  80. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  81. data/lib/spurline/security/gates/tool_result.rb +23 -0
  82. data/lib/spurline/security/gates/user_input.rb +22 -0
  83. data/lib/spurline/security/injection_scanner.rb +109 -0
  84. data/lib/spurline/security/pii_filter.rb +104 -0
  85. data/lib/spurline/session/resumption.rb +36 -0
  86. data/lib/spurline/session/serializer.rb +169 -0
  87. data/lib/spurline/session/session.rb +154 -0
  88. data/lib/spurline/session/store/base.rb +27 -0
  89. data/lib/spurline/session/store/memory.rb +45 -0
  90. data/lib/spurline/session/store/postgres.rb +123 -0
  91. data/lib/spurline/session/store/sqlite.rb +139 -0
  92. data/lib/spurline/session/suspension.rb +93 -0
  93. data/lib/spurline/session/turn.rb +98 -0
  94. data/lib/spurline/spur.rb +213 -0
  95. data/lib/spurline/streaming/buffer.rb +77 -0
  96. data/lib/spurline/streaming/chunk.rb +62 -0
  97. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  98. data/lib/spurline/testing.rb +245 -0
  99. data/lib/spurline/toolkit.rb +110 -0
  100. data/lib/spurline/tools/base.rb +209 -0
  101. data/lib/spurline/tools/idempotency.rb +220 -0
  102. data/lib/spurline/tools/permissions.rb +44 -0
  103. data/lib/spurline/tools/registry.rb +43 -0
  104. data/lib/spurline/tools/runner.rb +255 -0
  105. data/lib/spurline/tools/scope.rb +309 -0
  106. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  107. data/lib/spurline/version.rb +5 -0
  108. data/lib/spurline.rb +56 -0
  109. metadata +160 -0
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Spurline
6
+ module Session
7
+ module Store
8
+ # PostgreSQL-backed session store. Persists sessions across process restarts.
9
+ # Thread-safe via a single connection guarded by a Mutex.
10
+ class Postgres < Base
11
+ TABLE_NAME = "spurline_sessions"
12
+
13
+ def initialize(url: Spurline.config.session_store_postgres_url, serializer: Spurline::Session::Serializer.new)
14
+ @url = url
15
+ @serializer = serializer
16
+ @mutex = Mutex.new
17
+ @connection = nil
18
+ require_pg!
19
+ ensure_url!
20
+ end
21
+
22
+ def save(session)
23
+ now = Time.now.utc.iso8601(6)
24
+ payload = @serializer.to_json(session)
25
+
26
+ @mutex.synchronize do
27
+ connection.exec_params(
28
+ <<~SQL,
29
+ INSERT INTO #{TABLE_NAME} (id, state, agent_class, created_at, updated_at, data)
30
+ VALUES ($1, $2, $3, COALESCE((SELECT created_at FROM #{TABLE_NAME} WHERE id = $1), $4), $5, $6::jsonb)
31
+ ON CONFLICT (id) DO UPDATE SET
32
+ state = EXCLUDED.state,
33
+ agent_class = EXCLUDED.agent_class,
34
+ updated_at = EXCLUDED.updated_at,
35
+ data = EXCLUDED.data
36
+ SQL
37
+ [session.id, session.state.to_s, session.agent_class, now, now, payload]
38
+ )
39
+ end
40
+
41
+ session
42
+ end
43
+
44
+ # ASYNC-READY: Keep read path isolated for future async driver swap.
45
+ def load(id)
46
+ row = @mutex.synchronize do
47
+ result = connection.exec_params(
48
+ "SELECT data::text AS data FROM #{TABLE_NAME} WHERE id = $1 LIMIT 1",
49
+ [id]
50
+ )
51
+ result.ntuples.positive? ? result[0] : nil
52
+ end
53
+ return nil unless row
54
+
55
+ payload = row.fetch("data")
56
+ @serializer.from_json(payload, store: self)
57
+ end
58
+
59
+ def delete(id)
60
+ @mutex.synchronize do
61
+ connection.exec_params("DELETE FROM #{TABLE_NAME} WHERE id = $1", [id])
62
+ end
63
+ end
64
+
65
+ def exists?(id)
66
+ @mutex.synchronize do
67
+ result = connection.exec_params("SELECT 1 FROM #{TABLE_NAME} WHERE id = $1 LIMIT 1", [id])
68
+ result.ntuples.positive?
69
+ end
70
+ end
71
+
72
+ def size
73
+ @mutex.synchronize do
74
+ connection.exec("SELECT COUNT(*) FROM #{TABLE_NAME}")[0]["count"].to_i
75
+ end
76
+ end
77
+
78
+ def clear!
79
+ @mutex.synchronize do
80
+ connection.exec("DELETE FROM #{TABLE_NAME}")
81
+ end
82
+ end
83
+
84
+ def ids
85
+ @mutex.synchronize do
86
+ connection.exec("SELECT id FROM #{TABLE_NAME} ORDER BY id").map { |row| row.fetch("id") }
87
+ end
88
+ end
89
+
90
+ def close
91
+ @mutex.synchronize do
92
+ @connection&.close
93
+ @connection = nil
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def ensure_url!
100
+ return if @url && !@url.strip.empty?
101
+
102
+ raise Spurline::ConfigurationError,
103
+ "session_store_postgres_url must be set when using :postgres session store."
104
+ end
105
+
106
+ def require_pg!
107
+ return if defined?(::PG::Connection)
108
+
109
+ require "pg"
110
+ rescue LoadError
111
+ raise Spurline::PostgresUnavailableError,
112
+ "The 'pg' gem is required for the :postgres session store. Add gem \"pg\" to your Gemfile."
113
+ end
114
+
115
+ # ASYNC-READY: This connection boundary is where pooling/non-blocking
116
+ # clients can be introduced without changing the store contract.
117
+ def connection
118
+ @connection ||= PG.connect(@url)
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+
6
+ module Spurline
7
+ module Session
8
+ module Store
9
+ # SQLite-backed session store. Persists sessions across process restarts.
10
+ # Thread-safe via a single connection guarded by a Mutex.
11
+ class SQLite < Base
12
+ TABLE_NAME = "spurline_sessions"
13
+
14
+ def initialize(path: Spurline.config.session_store_path, serializer: Spurline::Session::Serializer.new)
15
+ @path = path
16
+ @serializer = serializer
17
+ @mutex = Mutex.new
18
+ @db = nil
19
+ end
20
+
21
+ def save(session)
22
+ now = Time.now.utc.iso8601(6)
23
+ payload = @serializer.to_json(session)
24
+
25
+ @mutex.synchronize do
26
+ db.execute(
27
+ <<~SQL,
28
+ INSERT OR REPLACE INTO #{TABLE_NAME}
29
+ (id, state, agent_class, created_at, updated_at, data)
30
+ VALUES (
31
+ ?,
32
+ ?,
33
+ ?,
34
+ COALESCE((SELECT created_at FROM #{TABLE_NAME} WHERE id = ?), ?),
35
+ ?,
36
+ ?
37
+ )
38
+ SQL
39
+ [session.id, session.state.to_s, session.agent_class, session.id, now, now, payload]
40
+ )
41
+ end
42
+
43
+ session
44
+ end
45
+
46
+ def load(id)
47
+ row = @mutex.synchronize do
48
+ db.get_first_row("SELECT data FROM #{TABLE_NAME} WHERE id = ? LIMIT 1", [id])
49
+ end
50
+ return nil unless row
51
+
52
+ payload = row.fetch("data")
53
+ @serializer.from_json(payload, store: self)
54
+ end
55
+
56
+ def delete(id)
57
+ @mutex.synchronize do
58
+ db.execute("DELETE FROM #{TABLE_NAME} WHERE id = ?", [id])
59
+ end
60
+ end
61
+
62
+ def exists?(id)
63
+ @mutex.synchronize do
64
+ !db.get_first_value("SELECT 1 FROM #{TABLE_NAME} WHERE id = ? LIMIT 1", [id]).nil?
65
+ end
66
+ end
67
+
68
+ def size
69
+ @mutex.synchronize do
70
+ db.get_first_value("SELECT COUNT(*) FROM #{TABLE_NAME}").to_i
71
+ end
72
+ end
73
+
74
+ def clear!
75
+ @mutex.synchronize do
76
+ db.execute("DELETE FROM #{TABLE_NAME}")
77
+ end
78
+ end
79
+
80
+ def ids
81
+ @mutex.synchronize do
82
+ db.execute("SELECT id FROM #{TABLE_NAME} ORDER BY id").map { |row| row.fetch("id") }
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ # ASYNC-READY: This connection boundary is where async adapters can
89
+ # introduce pooled or non-blocking persistence later.
90
+ def db
91
+ @db ||= begin
92
+ require_sqlite3!
93
+ ensure_parent_directory!
94
+ connection = ::SQLite3::Database.new(@path)
95
+ connection.results_as_hash = true
96
+ connection.busy_timeout(5_000)
97
+ connection.execute("PRAGMA journal_mode = WAL")
98
+ connection.execute("PRAGMA synchronous = NORMAL")
99
+ connection.execute(
100
+ <<~SQL
101
+ CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
102
+ id TEXT PRIMARY KEY,
103
+ state TEXT NOT NULL,
104
+ agent_class TEXT,
105
+ created_at TEXT NOT NULL,
106
+ updated_at TEXT NOT NULL,
107
+ data TEXT NOT NULL
108
+ )
109
+ SQL
110
+ )
111
+ connection.execute("CREATE INDEX IF NOT EXISTS idx_spurline_sessions_state ON #{TABLE_NAME}(state)")
112
+ connection.execute(
113
+ "CREATE INDEX IF NOT EXISTS idx_spurline_sessions_agent_class ON #{TABLE_NAME}(agent_class)"
114
+ )
115
+ connection
116
+ end
117
+ end
118
+
119
+ def require_sqlite3!
120
+ return if defined?(::SQLite3::Database)
121
+
122
+ require "sqlite3"
123
+ rescue LoadError
124
+ raise Spurline::SQLiteUnavailableError,
125
+ "sqlite3 gem is required for the :sqlite session store. Add gem \"sqlite3\" to your Gemfile."
126
+ end
127
+
128
+ def ensure_parent_directory!
129
+ return if @path == ":memory:"
130
+
131
+ parent = File.dirname(@path)
132
+ return if parent.nil? || parent == "." || parent.empty?
133
+
134
+ FileUtils.mkdir_p(parent)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Spurline
6
+ module Session
7
+ # Suspension logic for sessions.
8
+ # Kept as a standalone module so Session internals remain unchanged.
9
+ module Suspension
10
+ SUSPENSION_KEY = :suspension_checkpoint
11
+ SUSPENDABLE_STATES = %i[running waiting_for_tool processing].freeze
12
+
13
+ # Suspends a session, saving a checkpoint for later resumption.
14
+ def self.suspend!(session, checkpoint:)
15
+ raise Spurline::SuspensionError, "Session is already suspended" if suspended?(session)
16
+
17
+ unless suspendable?(session)
18
+ raise Spurline::SuspensionError,
19
+ "Session cannot be suspended from state #{session.state.inspect}"
20
+ end
21
+
22
+ session.metadata[SUSPENSION_KEY] = normalize_checkpoint(session, checkpoint)
23
+ session.transition_to!(:suspended)
24
+ persist!(session)
25
+ session
26
+ end
27
+
28
+ # Resumes a suspended session, clearing the checkpoint.
29
+ def self.resume!(session)
30
+ unless suspended?(session)
31
+ raise Spurline::InvalidResumeError,
32
+ "Session is not suspended (state=#{session.state.inspect})"
33
+ end
34
+
35
+ session.metadata[SUSPENSION_KEY] = nil
36
+ session.transition_to!(:running)
37
+ persist!(session)
38
+ session
39
+ end
40
+
41
+ # Returns true if session is in :suspended state.
42
+ def self.suspended?(session)
43
+ session.state == :suspended
44
+ end
45
+
46
+ # Returns the checkpoint hash, or nil if not suspended.
47
+ def self.checkpoint_for(session)
48
+ return nil unless suspended?(session)
49
+
50
+ session.metadata[SUSPENSION_KEY]
51
+ end
52
+
53
+ # Validates that a session can be suspended from its current state.
54
+ def self.suspendable?(session)
55
+ SUSPENDABLE_STATES.include?(session.state)
56
+ end
57
+
58
+ def self.normalize_checkpoint(session, checkpoint)
59
+ unless checkpoint.is_a?(Hash)
60
+ raise ArgumentError, "checkpoint must be a Hash"
61
+ end
62
+
63
+ {
64
+ loop_iteration: fetch(checkpoint, :loop_iteration) || 0,
65
+ last_tool_result: fetch(checkpoint, :last_tool_result),
66
+ messages_so_far: Array(fetch(checkpoint, :messages_so_far)),
67
+ turn_number: fetch(checkpoint, :turn_number) || infer_turn_number(session),
68
+ suspended_at: fetch(checkpoint, :suspended_at) || Time.now.utc.iso8601,
69
+ suspension_reason: fetch(checkpoint, :suspension_reason),
70
+ }
71
+ end
72
+ private_class_method :normalize_checkpoint
73
+
74
+ def self.fetch(hash, key)
75
+ hash[key] || hash[key.to_s]
76
+ end
77
+ private_class_method :fetch
78
+
79
+ def self.infer_turn_number(session)
80
+ return session.current_turn.number if session.respond_to?(:current_turn) && session.current_turn
81
+ return session.turn_count if session.respond_to?(:turn_count)
82
+
83
+ 0
84
+ end
85
+ private_class_method :infer_turn_number
86
+
87
+ def self.persist!(session)
88
+ session.send(:save!)
89
+ end
90
+ private_class_method :persist!
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Session
5
+ # A single turn in a conversation. Holds the input, output, tool calls,
6
+ # and timing information. Turns are mutable during their lifecycle and
7
+ # become effectively immutable once finished.
8
+ class Turn
9
+ attr_reader :input, :output, :tool_calls, :started_at, :finished_at,
10
+ :number, :metadata
11
+
12
+ def initialize(input:, number:)
13
+ @input = input
14
+ @output = nil
15
+ @tool_calls = []
16
+ @number = number
17
+ @started_at = Time.now
18
+ @finished_at = nil
19
+ @metadata = {}
20
+ end
21
+
22
+ # Rebuilds a turn from persisted attributes without running initialize.
23
+ def self.restore(data)
24
+ turn = allocate
25
+ turn.instance_variable_set(:@input, data[:input])
26
+ turn.instance_variable_set(:@output, data[:output])
27
+ turn.instance_variable_set(:@tool_calls, data[:tool_calls] || [])
28
+ turn.instance_variable_set(:@number, data[:number])
29
+ turn.instance_variable_set(:@started_at, data[:started_at])
30
+ turn.instance_variable_set(:@finished_at, data[:finished_at])
31
+ turn.instance_variable_set(:@metadata, data[:metadata] || {})
32
+ turn
33
+ end
34
+
35
+ def finish!(output:)
36
+ @output = output
37
+ @finished_at = Time.now
38
+ @metadata[:duration_ms] = duration_ms
39
+ end
40
+
41
+ def record_tool_call(
42
+ name:,
43
+ arguments:,
44
+ result:,
45
+ duration_ms:,
46
+ scope_id: nil,
47
+ idempotency_key: nil,
48
+ was_cached: nil,
49
+ cache_age_ms: nil
50
+ )
51
+ entry = {
52
+ name: name,
53
+ arguments: arguments,
54
+ result: result,
55
+ duration_ms: duration_ms,
56
+ timestamp: Time.now,
57
+ }
58
+ entry[:scope_id] = scope_id unless scope_id.nil?
59
+ entry[:idempotency_key] = idempotency_key unless idempotency_key.nil?
60
+ entry[:was_cached] = was_cached unless was_cached.nil?
61
+ entry[:cache_age_ms] = cache_age_ms unless cache_age_ms.nil?
62
+ @tool_calls << entry
63
+ end
64
+
65
+ # Duration in seconds (float).
66
+ def duration
67
+ return nil unless finished_at
68
+
69
+ finished_at - started_at
70
+ end
71
+
72
+ # Duration in milliseconds (integer), for audit logging.
73
+ def duration_ms
74
+ return nil unless finished_at
75
+
76
+ ((finished_at - started_at) * 1000).round
77
+ end
78
+
79
+ def tool_call_count
80
+ tool_calls.length
81
+ end
82
+
83
+ def complete?
84
+ !finished_at.nil?
85
+ end
86
+
87
+ # Compact summary for logging and debugging.
88
+ def summary
89
+ {
90
+ number: number,
91
+ tool_calls: tool_call_count,
92
+ duration_ms: duration_ms,
93
+ complete: complete?,
94
+ }
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ # Base class for spur gems. Spur gems are standard Ruby gems that self-register
5
+ # tools and permissions into the Spurline framework on require.
6
+ #
7
+ # The spur contract is locked — this interface cannot change after ship.
8
+ #
9
+ # Usage in a spur gem (e.g., spurline-web):
10
+ #
11
+ # module SpurlineWeb
12
+ # class Railtie < Spurline::Spur
13
+ # spur_name "spurline-web"
14
+ #
15
+ # tools do
16
+ # register :web_search, SpurlineWeb::Tools::WebSearch
17
+ # register :scrape, SpurlineWeb::Tools::Scraper
18
+ # end
19
+ #
20
+ # permissions do
21
+ # default_trust :external
22
+ # requires_confirmation false
23
+ # end
24
+ # end
25
+ # end
26
+ #
27
+ class Spur
28
+ class << self
29
+ # Track all registered spurs for introspection.
30
+ def registry
31
+ @registry ||= {}
32
+ end
33
+
34
+ # Tool registrations deferred because Agent wasn't loaded yet.
35
+ def pending_registrations
36
+ @pending_registrations ||= []
37
+ end
38
+
39
+ # Adapter registrations deferred because Agent wasn't loaded yet.
40
+ def pending_adapter_registrations
41
+ @pending_adapter_registrations ||= []
42
+ end
43
+
44
+ # Replay deferred tool registrations into the given tool registry.
45
+ def flush_pending_registrations!(registry)
46
+ return if pending_registrations.empty?
47
+
48
+ pending_registrations.each do |registration|
49
+ registry.register(registration[:name], registration[:tool_class])
50
+ end
51
+ pending_registrations.clear
52
+ end
53
+
54
+ # Replay deferred adapter registrations into the given adapter registry.
55
+ def flush_pending_adapter_registrations!(registry)
56
+ return if pending_adapter_registrations.empty?
57
+
58
+ pending_adapter_registrations.each do |registration|
59
+ registry.register(registration[:name], registration[:adapter_class])
60
+ end
61
+ pending_adapter_registrations.clear
62
+ end
63
+
64
+ # Called by subclasses to set the spur gem name.
65
+ def spur_name(name = nil)
66
+ if name
67
+ @spur_name = name
68
+ else
69
+ @spur_name || self.name
70
+ end
71
+ end
72
+
73
+ # DSL block for registering tools.
74
+ def tools(&block)
75
+ @tool_registrations ||= []
76
+ if block
77
+ context = ToolRegistrationContext.new
78
+ context.instance_eval(&block)
79
+ @tool_registrations = context.registrations
80
+ end
81
+ @tool_registrations
82
+ end
83
+
84
+ # DSL block for registering adapters.
85
+ def adapters(&block)
86
+ @adapter_registrations ||= []
87
+ if block
88
+ context = AdapterRegistrationContext.new
89
+ context.instance_eval(&block)
90
+ @adapter_registrations = context.registrations
91
+ end
92
+ @adapter_registrations
93
+ end
94
+
95
+ # DSL block for declaring default permissions.
96
+ def permissions(&block)
97
+ @permission_defaults ||= {}
98
+ if block
99
+ context = PermissionContext.new
100
+ context.instance_eval(&block)
101
+ @permission_defaults = context.settings
102
+ end
103
+ @permission_defaults
104
+ end
105
+
106
+ # Hook called when a subclass is defined. Auto-registers the spur.
107
+ def inherited(subclass)
108
+ super
109
+ # Defer registration to allow the class body to execute first.
110
+ TracePoint.new(:end) do |tp|
111
+ if tp.self == subclass
112
+ tp.disable
113
+ subclass.send(:auto_register!)
114
+ end
115
+ end.enable
116
+ end
117
+
118
+ private
119
+
120
+ # Auto-registers this spur's tools and adapters into the global
121
+ # Spurline::Agent registries. If Agent hasn't been loaded yet (Zeitwerk
122
+ # lazy loading), the registrations are deferred and replayed when the
123
+ # respective registry is first accessed.
124
+ def auto_register!
125
+ Spur.registry[spur_name] = {
126
+ tools: tools.map { |r| r[:name] },
127
+ adapters: adapters.map { |r| r[:name] },
128
+ permissions: permissions,
129
+ }
130
+
131
+ register_tools!
132
+ register_adapters!
133
+ end
134
+
135
+ def register_tools!
136
+ return if tools.empty?
137
+
138
+ if defined?(Spurline::Agent) && Spurline::Agent.respond_to?(:tool_registry)
139
+ tools.each do |registration|
140
+ Spurline::Agent.tool_registry.register(
141
+ registration[:name],
142
+ registration[:tool_class]
143
+ )
144
+ end
145
+ else
146
+ Spur.pending_registrations.concat(tools)
147
+ end
148
+ end
149
+
150
+ def register_adapters!
151
+ return if adapters.empty?
152
+
153
+ if defined?(Spurline::Agent) && Spurline::Agent.respond_to?(:adapter_registry)
154
+ adapters.each do |registration|
155
+ Spurline::Agent.adapter_registry.register(
156
+ registration[:name],
157
+ registration[:adapter_class]
158
+ )
159
+ end
160
+ else
161
+ Spur.pending_adapter_registrations.concat(adapters)
162
+ end
163
+ end
164
+ end
165
+
166
+ # Context object for the `tools` DSL block.
167
+ class ToolRegistrationContext
168
+ attr_reader :registrations
169
+
170
+ def initialize
171
+ @registrations = []
172
+ end
173
+
174
+ def register(name, tool_class)
175
+ @registrations << { name: name.to_sym, tool_class: tool_class }
176
+ end
177
+ end
178
+
179
+ # Context object for the `adapters` DSL block.
180
+ class AdapterRegistrationContext
181
+ attr_reader :registrations
182
+
183
+ def initialize
184
+ @registrations = []
185
+ end
186
+
187
+ def register(name, adapter_class)
188
+ @registrations << { name: name.to_sym, adapter_class: adapter_class }
189
+ end
190
+ end
191
+
192
+ # Context object for the `permissions` DSL block.
193
+ class PermissionContext
194
+ attr_reader :settings
195
+
196
+ def initialize
197
+ @settings = {}
198
+ end
199
+
200
+ def default_trust(level)
201
+ @settings[:default_trust] = level
202
+ end
203
+
204
+ def requires_confirmation(val = true)
205
+ @settings[:requires_confirmation] = val
206
+ end
207
+
208
+ def sandbox(val = true)
209
+ @settings[:sandbox] = val
210
+ end
211
+ end
212
+ end
213
+ end