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.
- checksums.yaml +7 -0
- data/lib/spurline/adapters/base.rb +17 -0
- data/lib/spurline/adapters/claude.rb +208 -0
- data/lib/spurline/adapters/open_ai.rb +213 -0
- data/lib/spurline/adapters/registry.rb +33 -0
- data/lib/spurline/adapters/scheduler/base.rb +15 -0
- data/lib/spurline/adapters/scheduler/sync.rb +15 -0
- data/lib/spurline/adapters/stub_adapter.rb +54 -0
- data/lib/spurline/agent.rb +433 -0
- data/lib/spurline/audit/log.rb +156 -0
- data/lib/spurline/audit/secret_filter.rb +121 -0
- data/lib/spurline/base.rb +130 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
- data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
- data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
- data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
- data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
- data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
- data/lib/spurline/cartographer/repo_profile.rb +140 -0
- data/lib/spurline/cartographer/runner.rb +88 -0
- data/lib/spurline/cartographer.rb +6 -0
- data/lib/spurline/channels/base.rb +41 -0
- data/lib/spurline/channels/event.rb +136 -0
- data/lib/spurline/channels/github.rb +205 -0
- data/lib/spurline/channels/router.rb +103 -0
- data/lib/spurline/cli/check.rb +88 -0
- data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
- data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
- data/lib/spurline/cli/checks/base.rb +35 -0
- data/lib/spurline/cli/checks/credentials.rb +43 -0
- data/lib/spurline/cli/checks/permissions.rb +22 -0
- data/lib/spurline/cli/checks/project_structure.rb +48 -0
- data/lib/spurline/cli/checks/session_store.rb +97 -0
- data/lib/spurline/cli/console.rb +73 -0
- data/lib/spurline/cli/credentials.rb +181 -0
- data/lib/spurline/cli/generators/agent.rb +123 -0
- data/lib/spurline/cli/generators/migration.rb +62 -0
- data/lib/spurline/cli/generators/project.rb +331 -0
- data/lib/spurline/cli/generators/tool.rb +98 -0
- data/lib/spurline/cli/router.rb +121 -0
- data/lib/spurline/configuration.rb +23 -0
- data/lib/spurline/dsl/guardrails.rb +108 -0
- data/lib/spurline/dsl/hooks.rb +51 -0
- data/lib/spurline/dsl/memory.rb +39 -0
- data/lib/spurline/dsl/model.rb +23 -0
- data/lib/spurline/dsl/persona.rb +74 -0
- data/lib/spurline/dsl/suspend_until.rb +53 -0
- data/lib/spurline/dsl/tools.rb +176 -0
- data/lib/spurline/errors.rb +109 -0
- data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
- data/lib/spurline/lifecycle/runner.rb +456 -0
- data/lib/spurline/lifecycle/states.rb +47 -0
- data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/base.rb +17 -0
- data/lib/spurline/memory/embedder/open_ai.rb +70 -0
- data/lib/spurline/memory/episode.rb +56 -0
- data/lib/spurline/memory/episodic_store.rb +147 -0
- data/lib/spurline/memory/long_term/base.rb +22 -0
- data/lib/spurline/memory/long_term/postgres.rb +106 -0
- data/lib/spurline/memory/manager.rb +147 -0
- data/lib/spurline/memory/short_term.rb +57 -0
- data/lib/spurline/orchestration/agent_spawner.rb +151 -0
- data/lib/spurline/orchestration/judge.rb +109 -0
- data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
- data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
- data/lib/spurline/orchestration/ledger.rb +339 -0
- data/lib/spurline/orchestration/merge_queue.rb +133 -0
- data/lib/spurline/orchestration/permission_intersection.rb +151 -0
- data/lib/spurline/orchestration/task_envelope.rb +201 -0
- data/lib/spurline/persona/base.rb +42 -0
- data/lib/spurline/persona/registry.rb +42 -0
- data/lib/spurline/secrets/resolver.rb +65 -0
- data/lib/spurline/secrets/vault.rb +42 -0
- data/lib/spurline/security/content.rb +76 -0
- data/lib/spurline/security/context_pipeline.rb +58 -0
- data/lib/spurline/security/gates/base.rb +36 -0
- data/lib/spurline/security/gates/operator_config.rb +22 -0
- data/lib/spurline/security/gates/system_prompt.rb +23 -0
- data/lib/spurline/security/gates/tool_result.rb +23 -0
- data/lib/spurline/security/gates/user_input.rb +22 -0
- data/lib/spurline/security/injection_scanner.rb +109 -0
- data/lib/spurline/security/pii_filter.rb +104 -0
- data/lib/spurline/session/resumption.rb +36 -0
- data/lib/spurline/session/serializer.rb +169 -0
- data/lib/spurline/session/session.rb +154 -0
- data/lib/spurline/session/store/base.rb +27 -0
- data/lib/spurline/session/store/memory.rb +45 -0
- data/lib/spurline/session/store/postgres.rb +123 -0
- data/lib/spurline/session/store/sqlite.rb +139 -0
- data/lib/spurline/session/suspension.rb +93 -0
- data/lib/spurline/session/turn.rb +98 -0
- data/lib/spurline/spur.rb +213 -0
- data/lib/spurline/streaming/buffer.rb +77 -0
- data/lib/spurline/streaming/chunk.rb +62 -0
- data/lib/spurline/streaming/stream_enumerator.rb +29 -0
- data/lib/spurline/testing.rb +245 -0
- data/lib/spurline/toolkit.rb +110 -0
- data/lib/spurline/tools/base.rb +209 -0
- data/lib/spurline/tools/idempotency.rb +220 -0
- data/lib/spurline/tools/permissions.rb +44 -0
- data/lib/spurline/tools/registry.rb +43 -0
- data/lib/spurline/tools/runner.rb +255 -0
- data/lib/spurline/tools/scope.rb +309 -0
- data/lib/spurline/tools/toolkit_registry.rb +63 -0
- data/lib/spurline/version.rb +5 -0
- data/lib/spurline.rb +56 -0
- 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
|