spurline-deploy 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 +161 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Security
|
|
5
|
+
# Scans Content objects for prompt injection patterns.
|
|
6
|
+
# Configurable strictness: :strict, :moderate, :permissive.
|
|
7
|
+
#
|
|
8
|
+
# Only scans content at trust levels that could be injected (:user, :external, :untrusted).
|
|
9
|
+
# System and operator content is trusted by definition and bypasses scanning.
|
|
10
|
+
#
|
|
11
|
+
# Pattern tiers are additive: :strict includes all :moderate patterns,
|
|
12
|
+
# :moderate includes all :permissive (BASE) patterns.
|
|
13
|
+
class InjectionScanner
|
|
14
|
+
SKIP_TRUST_LEVELS = %i[system operator].freeze
|
|
15
|
+
|
|
16
|
+
# Patterns checked at all strictness levels — the most obvious injection attempts.
|
|
17
|
+
BASE_PATTERNS = [
|
|
18
|
+
/ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions|prompts|context|rules)/i,
|
|
19
|
+
/you\s+are\s+now\s+(a|an|in)\s+/i,
|
|
20
|
+
/\bsystem\s*:\s*\n/i,
|
|
21
|
+
/\bforget\s+(all\s+|everything\s+)?(previous|prior|your)\s+(instructions|context|rules|training)/i,
|
|
22
|
+
/\bdisregard\s+(all\s+)?(previous|prior|above|your)\s+(instructions|prompts|rules)/i,
|
|
23
|
+
/\bnew\s+instructions\s*:/i,
|
|
24
|
+
/\bpretend\s+(you\s+are|to\s+be|that\s+you)/i,
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# Additional patterns for :moderate and :strict — social engineering and role manipulation.
|
|
28
|
+
MODERATE_PATTERNS = [
|
|
29
|
+
/\bdo\s+not\s+follow\b/i,
|
|
30
|
+
/\boverride\s+(your|the)\s+(instructions|rules|guidelines|programming)\b/i,
|
|
31
|
+
/\bact\s+as\s+(if\s+you\s+are|though\s+you|a\b)/i,
|
|
32
|
+
/\bbehave\s+as\s+(if|though|a\b)/i,
|
|
33
|
+
/\bfrom\s+now\s+on\s*,?\s*(you|your|act|behave|respond|ignore)/i,
|
|
34
|
+
/\bjailbreak/i,
|
|
35
|
+
/\bdeveloper\s+mode\b/i,
|
|
36
|
+
/\bDAN\s+(mode|prompt)\b/i,
|
|
37
|
+
/\bdo\s+anything\s+now\b/i,
|
|
38
|
+
/\bunfiltered\s+(mode|response|output)\b/i,
|
|
39
|
+
/\bno\s+(restrictions|rules|limitations|filters|censorship)\b/i,
|
|
40
|
+
/\bbypass\s+(your|the|any|all)\s+(restrictions|rules|filters|safety|guidelines)/i,
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
# Additional patterns for :strict only — structural attacks and format manipulation.
|
|
44
|
+
STRICT_PATTERNS = [
|
|
45
|
+
/\brole\s*:\s*(system|assistant)\b/i,
|
|
46
|
+
/<\/?system>/i,
|
|
47
|
+
/\[INST\]/i,
|
|
48
|
+
/<<\s*SYS\s*>>/i,
|
|
49
|
+
/<\|im_start\|>/i,
|
|
50
|
+
/\bIMPORTANT\s*:\s*(new|override|ignore|forget|disregard|update)/i,
|
|
51
|
+
/\bATTENTION\s*:\s*(new|override|ignore|forget|disregard|update)/i,
|
|
52
|
+
/\b(BEGIN|END)\s+(SYSTEM|INSTRUCTION|PROMPT)\b/i,
|
|
53
|
+
/---+\s*\n\s*(system|instruction|new prompt|override)/i,
|
|
54
|
+
/\bbase64\s*[\s:]+[A-Za-z0-9+\/=]{20,}/i,
|
|
55
|
+
/\btranslate\s+(the\s+)?(following|this)\s+(from|to)\s+.*\s+(and|then)\s+(ignore|forget|override)/i,
|
|
56
|
+
/\brepeat\s+(the\s+)?(system\s+prompt|instructions|your\s+rules)/i,
|
|
57
|
+
/\b(reveal|show|display|output|print)\s+(your|the)\s+(system\s+prompt|instructions|rules)/i,
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
LEVELS = %i[strict moderate permissive].freeze
|
|
61
|
+
|
|
62
|
+
attr_reader :level
|
|
63
|
+
|
|
64
|
+
def initialize(level: :strict)
|
|
65
|
+
validate_level!(level)
|
|
66
|
+
@level = level
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Scans a Content object for injection patterns.
|
|
70
|
+
# Returns nil if clean, raises InjectionAttemptError if detected.
|
|
71
|
+
def scan!(content)
|
|
72
|
+
return if SKIP_TRUST_LEVELS.include?(content.trust)
|
|
73
|
+
|
|
74
|
+
text = content.text
|
|
75
|
+
patterns_for_level.each do |pattern|
|
|
76
|
+
next unless text.match?(pattern)
|
|
77
|
+
|
|
78
|
+
raise Spurline::InjectionAttemptError,
|
|
79
|
+
"Injection pattern detected in content (trust: #{content.trust}, " \
|
|
80
|
+
"source: #{content.source}). Pattern: #{pattern.source[0..40]}. " \
|
|
81
|
+
"Review the content or adjust injection_filter level."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def patterns_for_level
|
|
90
|
+
case level
|
|
91
|
+
when :strict
|
|
92
|
+
BASE_PATTERNS + MODERATE_PATTERNS + STRICT_PATTERNS
|
|
93
|
+
when :moderate
|
|
94
|
+
BASE_PATTERNS + MODERATE_PATTERNS
|
|
95
|
+
when :permissive
|
|
96
|
+
BASE_PATTERNS
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_level!(level)
|
|
101
|
+
return if LEVELS.include?(level)
|
|
102
|
+
|
|
103
|
+
raise Spurline::ConfigurationError,
|
|
104
|
+
"Invalid injection filter level: #{level.inspect}. " \
|
|
105
|
+
"Must be one of: #{LEVELS.map(&:inspect).join(", ")}."
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Security
|
|
5
|
+
# Filters personally identifiable information from Content objects.
|
|
6
|
+
# Modes:
|
|
7
|
+
# :redact — replaces detected PII with [REDACTED_<type>] placeholders
|
|
8
|
+
# :block — raises PIIDetectedError if PII is found
|
|
9
|
+
# :warn — returns content unchanged but records detections for audit
|
|
10
|
+
# :off — no scanning, returns content unchanged
|
|
11
|
+
#
|
|
12
|
+
# Only scans content at trust levels where PII matters (:user, :external, :untrusted).
|
|
13
|
+
# System and operator content is trusted by definition and bypasses PII filtering.
|
|
14
|
+
class PIIFilter
|
|
15
|
+
MODES = %i[redact block warn off].freeze
|
|
16
|
+
|
|
17
|
+
SKIP_TRUST_LEVELS = %i[system operator].freeze
|
|
18
|
+
|
|
19
|
+
# Pattern definitions: [label, regex, replacement_tag]
|
|
20
|
+
# Ordered from most specific to least specific to prevent partial matches.
|
|
21
|
+
PII_PATTERNS = [
|
|
22
|
+
[:ssn, /\b\d{3}-\d{2}-\d{4}\b/, "[REDACTED_SSN]"],
|
|
23
|
+
[:credit_card, /\b(?:\d{4}[\s-]?){3}\d{4}\b/, "[REDACTED_CREDIT_CARD]"],
|
|
24
|
+
[:phone, /\b(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b/, "[REDACTED_PHONE]"],
|
|
25
|
+
[:email, /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/, "[REDACTED_EMAIL]"],
|
|
26
|
+
[:ip_address, /\b(?:\d{1,3}\.){3}\d{1,3}\b/, "[REDACTED_IP]"],
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
attr_reader :mode
|
|
30
|
+
|
|
31
|
+
def initialize(mode: :off)
|
|
32
|
+
validate_mode!(mode)
|
|
33
|
+
@mode = mode
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Filters a Content object for PII.
|
|
37
|
+
# Returns the original content if no PII is detected or mode is :off.
|
|
38
|
+
# Returns a new Content object with redacted text in :redact mode.
|
|
39
|
+
# Raises PIIDetectedError in :block mode.
|
|
40
|
+
# Returns the original content with detections array in :warn mode.
|
|
41
|
+
def filter(content)
|
|
42
|
+
return content if mode == :off
|
|
43
|
+
return content if SKIP_TRUST_LEVELS.include?(content.trust)
|
|
44
|
+
|
|
45
|
+
detections = detect(content.text)
|
|
46
|
+
return content if detections.empty?
|
|
47
|
+
|
|
48
|
+
case mode
|
|
49
|
+
when :redact
|
|
50
|
+
redact(content, detections)
|
|
51
|
+
when :block
|
|
52
|
+
block!(content, detections)
|
|
53
|
+
when :warn
|
|
54
|
+
# In :warn mode, content passes through unchanged.
|
|
55
|
+
# The caller (ContextPipeline) can check detections via the return.
|
|
56
|
+
# For now, we simply return the content — audit logging is deferred.
|
|
57
|
+
content
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Scans text for PII patterns. Returns array of {type:, match:, pattern:} hashes.
|
|
62
|
+
def detect(text)
|
|
63
|
+
detections = []
|
|
64
|
+
PII_PATTERNS.each do |label, pattern, _|
|
|
65
|
+
text.scan(pattern) do |match|
|
|
66
|
+
detected = match.is_a?(Array) ? match.first : $~.to_s
|
|
67
|
+
detections << { type: label, match: detected, pattern: pattern }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
detections
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def redact(content, detections)
|
|
76
|
+
redacted_text = content.text.dup
|
|
77
|
+
PII_PATTERNS.each do |_, pattern, replacement|
|
|
78
|
+
redacted_text.gsub!(pattern, replacement)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Content.new(
|
|
82
|
+
text: redacted_text,
|
|
83
|
+
trust: content.trust,
|
|
84
|
+
source: content.source
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def block!(content, detections)
|
|
89
|
+
types = detections.map { |d| d[:type] }.uniq.join(", ")
|
|
90
|
+
raise Spurline::PIIDetectedError,
|
|
91
|
+
"PII detected in content (trust: #{content.trust}, source: #{content.source}). " \
|
|
92
|
+
"Types found: #{types}. Set pii_filter to :redact or :off to allow this content."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def validate_mode!(mode)
|
|
96
|
+
return if MODES.include?(mode)
|
|
97
|
+
|
|
98
|
+
raise Spurline::ConfigurationError,
|
|
99
|
+
"Invalid PII filter mode: #{mode.inspect}. " \
|
|
100
|
+
"Must be one of: #{MODES.map(&:inspect).join(", ")}."
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Session
|
|
5
|
+
# Handles restoring a resumed session's turn history into short-term memory.
|
|
6
|
+
# When a session is resumed by ID, completed turns are replayed into the
|
|
7
|
+
# memory manager so the agent has context from the previous conversation.
|
|
8
|
+
class Resumption
|
|
9
|
+
attr_reader :restored_count
|
|
10
|
+
|
|
11
|
+
def initialize(session:, memory:)
|
|
12
|
+
@session = session
|
|
13
|
+
@memory = memory
|
|
14
|
+
@restored_count = 0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Restores the session's completed turn history into the memory manager.
|
|
18
|
+
# Incomplete turns are skipped — they represent interrupted work.
|
|
19
|
+
def restore!
|
|
20
|
+
@session.turns.each do |turn|
|
|
21
|
+
next unless turn.complete?
|
|
22
|
+
|
|
23
|
+
@memory.add_turn(turn)
|
|
24
|
+
@restored_count += 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@restored_count
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Whether this session has prior turns to restore.
|
|
31
|
+
def resumable?
|
|
32
|
+
@session.turns.any?(&:complete?)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Spurline
|
|
7
|
+
module Session
|
|
8
|
+
# JSON serializer/deserializer for persisted sessions.
|
|
9
|
+
# Includes a format_version field for forward-compatible migrations.
|
|
10
|
+
class Serializer
|
|
11
|
+
FORMAT_VERSION = 1
|
|
12
|
+
CONTENT_TYPE = "Spurline::Security::Content"
|
|
13
|
+
TIME_TYPE = "Time"
|
|
14
|
+
SYMBOL_TYPE = "Symbol"
|
|
15
|
+
|
|
16
|
+
def to_json(session)
|
|
17
|
+
JSON.generate(
|
|
18
|
+
format_version: FORMAT_VERSION,
|
|
19
|
+
session: serialize_session(session)
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def from_json(json, store:)
|
|
24
|
+
payload = JSON.parse(json)
|
|
25
|
+
unless payload.is_a?(Hash)
|
|
26
|
+
raise Spurline::SessionDeserializationError, "Session payload must be a JSON object."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
version = payload.fetch("format_version")
|
|
30
|
+
unless version == FORMAT_VERSION
|
|
31
|
+
raise Spurline::SessionDeserializationError,
|
|
32
|
+
"Unsupported session format version: #{version.inspect}."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
session_data = deserialize_session(payload.fetch("session"))
|
|
36
|
+
Session.restore(session_data, store: store)
|
|
37
|
+
rescue Spurline::SessionDeserializationError
|
|
38
|
+
raise
|
|
39
|
+
rescue JSON::ParserError, KeyError, TypeError, NoMethodError, ArgumentError, Spurline::ConfigurationError => e
|
|
40
|
+
raise Spurline::SessionDeserializationError, "Failed to deserialize session payload: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def serialize_session(session)
|
|
46
|
+
{
|
|
47
|
+
id: session.id,
|
|
48
|
+
agent_class: session.agent_class,
|
|
49
|
+
user: session.user,
|
|
50
|
+
state: session.state.to_s,
|
|
51
|
+
started_at: serialize_value(session.started_at),
|
|
52
|
+
finished_at: serialize_value(session.finished_at),
|
|
53
|
+
metadata: serialize_value(session.metadata),
|
|
54
|
+
turns: session.turns.map { |turn| serialize_turn(turn) },
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def serialize_turn(turn)
|
|
59
|
+
{
|
|
60
|
+
input: serialize_value(turn.input),
|
|
61
|
+
output: serialize_value(turn.output),
|
|
62
|
+
tool_calls: serialize_value(turn.tool_calls),
|
|
63
|
+
number: turn.number,
|
|
64
|
+
started_at: serialize_value(turn.started_at),
|
|
65
|
+
finished_at: serialize_value(turn.finished_at),
|
|
66
|
+
metadata: serialize_value(turn.metadata),
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def serialize_value(value)
|
|
71
|
+
case value
|
|
72
|
+
when Spurline::Security::Content
|
|
73
|
+
{
|
|
74
|
+
__type: CONTENT_TYPE,
|
|
75
|
+
text: value.text,
|
|
76
|
+
trust: value.trust.to_s,
|
|
77
|
+
source: value.source,
|
|
78
|
+
}
|
|
79
|
+
when Time
|
|
80
|
+
{
|
|
81
|
+
__type: TIME_TYPE,
|
|
82
|
+
iso8601: value.utc.iso8601(6),
|
|
83
|
+
}
|
|
84
|
+
when Symbol
|
|
85
|
+
{
|
|
86
|
+
__type: SYMBOL_TYPE,
|
|
87
|
+
value: value.to_s,
|
|
88
|
+
}
|
|
89
|
+
when Array
|
|
90
|
+
value.map { |item| serialize_value(item) }
|
|
91
|
+
when Hash
|
|
92
|
+
value.each_with_object({}) do |(key, item), hash|
|
|
93
|
+
hash[key.to_s] = serialize_value(item)
|
|
94
|
+
end
|
|
95
|
+
else
|
|
96
|
+
value
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def deserialize_session(data)
|
|
101
|
+
hash = deserialize_hash(data)
|
|
102
|
+
{
|
|
103
|
+
id: hash.fetch(:id),
|
|
104
|
+
agent_class: hash[:agent_class],
|
|
105
|
+
user: hash[:user],
|
|
106
|
+
turns: Array(hash[:turns]).map { |turn_data| Turn.restore(deserialize_turn(turn_data)) },
|
|
107
|
+
state: hash.fetch(:state).to_sym,
|
|
108
|
+
started_at: hash.fetch(:started_at),
|
|
109
|
+
finished_at: hash[:finished_at],
|
|
110
|
+
metadata: hash[:metadata] || {},
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def deserialize_turn(data)
|
|
115
|
+
hash = deserialize_hash(data)
|
|
116
|
+
{
|
|
117
|
+
input: hash[:input],
|
|
118
|
+
output: hash[:output],
|
|
119
|
+
tool_calls: hash[:tool_calls] || [],
|
|
120
|
+
number: hash.fetch(:number),
|
|
121
|
+
started_at: hash.fetch(:started_at),
|
|
122
|
+
finished_at: hash[:finished_at],
|
|
123
|
+
metadata: hash[:metadata] || {},
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def deserialize_hash(value)
|
|
128
|
+
hash = deserialize_value(value)
|
|
129
|
+
unless hash.is_a?(Hash)
|
|
130
|
+
raise TypeError, "Expected Hash value, got #{hash.class}."
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
hash
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def deserialize_value(value)
|
|
137
|
+
case value
|
|
138
|
+
when Array
|
|
139
|
+
value.map { |item| deserialize_value(item) }
|
|
140
|
+
when Hash
|
|
141
|
+
deserialize_hash_value(value)
|
|
142
|
+
else
|
|
143
|
+
value
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def deserialize_hash_value(value)
|
|
148
|
+
type = value["__type"]
|
|
149
|
+
|
|
150
|
+
case type
|
|
151
|
+
when CONTENT_TYPE
|
|
152
|
+
Spurline::Security::Content.new(
|
|
153
|
+
text: value.fetch("text"),
|
|
154
|
+
trust: value.fetch("trust").to_sym,
|
|
155
|
+
source: value.fetch("source")
|
|
156
|
+
)
|
|
157
|
+
when TIME_TYPE
|
|
158
|
+
Time.iso8601(value.fetch("iso8601"))
|
|
159
|
+
when SYMBOL_TYPE
|
|
160
|
+
value.fetch("value").to_sym
|
|
161
|
+
else
|
|
162
|
+
value.each_with_object({}) do |(key, item), hash|
|
|
163
|
+
hash[key.to_sym] = deserialize_value(item)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module Session
|
|
7
|
+
# The running record of an agent conversation. Framework-owned (ADR-004).
|
|
8
|
+
# Sessions are created via .load_or_create — never call .new directly in agent code.
|
|
9
|
+
#
|
|
10
|
+
# State transitions are enforced via Lifecycle::States.
|
|
11
|
+
class Session
|
|
12
|
+
attr_reader :id, :agent_class, :user, :turns, :state,
|
|
13
|
+
:started_at, :finished_at, :metadata
|
|
14
|
+
|
|
15
|
+
def initialize(id:, store:, agent_class: nil, user: nil)
|
|
16
|
+
@id = id
|
|
17
|
+
@store = store
|
|
18
|
+
@agent_class = agent_class
|
|
19
|
+
@user = user
|
|
20
|
+
@turns = []
|
|
21
|
+
@state = :ready
|
|
22
|
+
@started_at = Time.now
|
|
23
|
+
@finished_at = nil
|
|
24
|
+
@metadata = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The only way to get a session. Loads an existing session by ID,
|
|
28
|
+
# or creates a new one if it doesn't exist.
|
|
29
|
+
def self.load_or_create(id: nil, store:, **opts)
|
|
30
|
+
id ||= SecureRandom.uuid
|
|
31
|
+
|
|
32
|
+
if store.exists?(id)
|
|
33
|
+
store.load(id)
|
|
34
|
+
else
|
|
35
|
+
session = new(id: id, store: store, **opts)
|
|
36
|
+
store.save(session)
|
|
37
|
+
session
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Rebuilds a session from persisted attributes without running initialize.
|
|
42
|
+
def self.restore(data, store:)
|
|
43
|
+
session = allocate
|
|
44
|
+
session.instance_variable_set(:@id, data[:id])
|
|
45
|
+
session.instance_variable_set(:@store, store)
|
|
46
|
+
session.instance_variable_set(:@agent_class, data[:agent_class])
|
|
47
|
+
session.instance_variable_set(:@user, data[:user])
|
|
48
|
+
session.instance_variable_set(:@turns, data[:turns])
|
|
49
|
+
session.instance_variable_set(:@state, data[:state])
|
|
50
|
+
session.instance_variable_set(:@started_at, data[:started_at])
|
|
51
|
+
session.instance_variable_set(:@finished_at, data[:finished_at])
|
|
52
|
+
session.instance_variable_set(:@metadata, data[:metadata] || {})
|
|
53
|
+
session
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def start_turn(input:)
|
|
57
|
+
turn = Turn.new(input: input, number: turns.length + 1)
|
|
58
|
+
@turns << turn
|
|
59
|
+
@metadata[:last_turn_started_at] = turn.started_at
|
|
60
|
+
turn
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def current_turn
|
|
64
|
+
@turns.last
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def finish_turn!(output:)
|
|
68
|
+
current_turn&.finish!(output: output)
|
|
69
|
+
save!
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def tool_calls
|
|
73
|
+
turns.flat_map(&:tool_calls)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def tool_call_count
|
|
77
|
+
tool_calls.length
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def turn_count
|
|
81
|
+
turns.length
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Enforces valid state transitions via Lifecycle::States.
|
|
85
|
+
def transition_to!(new_state)
|
|
86
|
+
Lifecycle::States.validate_transition!(@state, new_state)
|
|
87
|
+
@state = new_state
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def complete!
|
|
91
|
+
@state = :complete
|
|
92
|
+
@finished_at = Time.now
|
|
93
|
+
@metadata[:total_turns] = turn_count
|
|
94
|
+
@metadata[:total_tool_calls] = tool_call_count
|
|
95
|
+
@metadata[:total_duration_ms] = total_duration_ms
|
|
96
|
+
save!
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def error!(error = nil)
|
|
100
|
+
@state = :error
|
|
101
|
+
@finished_at = Time.now
|
|
102
|
+
@metadata[:last_error] = error&.message
|
|
103
|
+
@metadata[:last_error_class] = error&.class&.name
|
|
104
|
+
save!
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Suspends the session and persists a checkpoint for later resumption.
|
|
108
|
+
def suspend!(checkpoint:)
|
|
109
|
+
Suspension.suspend!(self, checkpoint: checkpoint)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Resumes a suspended session and clears the persisted checkpoint.
|
|
113
|
+
def resume!
|
|
114
|
+
Suspension.resume!(self)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Whether the session is currently suspended.
|
|
118
|
+
def suspended?
|
|
119
|
+
Suspension.suspended?(self)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Duration in seconds (float).
|
|
123
|
+
def duration
|
|
124
|
+
return nil unless finished_at
|
|
125
|
+
|
|
126
|
+
finished_at - started_at
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Duration in milliseconds (integer).
|
|
130
|
+
def total_duration_ms
|
|
131
|
+
return nil unless finished_at
|
|
132
|
+
|
|
133
|
+
((finished_at - started_at) * 1000).round
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Compact summary for logging and debugging.
|
|
137
|
+
def summary
|
|
138
|
+
{
|
|
139
|
+
id: id,
|
|
140
|
+
state: state,
|
|
141
|
+
turns: turn_count,
|
|
142
|
+
tool_calls: tool_call_count,
|
|
143
|
+
duration_ms: total_duration_ms,
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def save!
|
|
150
|
+
@store.save(self)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Session
|
|
5
|
+
module Store
|
|
6
|
+
# Abstract interface for session storage adapters (ADR-004).
|
|
7
|
+
# The framework owns session persistence — developers do not manage it.
|
|
8
|
+
class Base
|
|
9
|
+
def save(session)
|
|
10
|
+
raise NotImplementedError, "#{self.class.name} must implement #save"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def load(id)
|
|
14
|
+
raise NotImplementedError, "#{self.class.name} must implement #load"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def delete(id)
|
|
18
|
+
raise NotImplementedError, "#{self.class.name} must implement #delete"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def exists?(id)
|
|
22
|
+
raise NotImplementedError, "#{self.class.name} must implement #exists?"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Session
|
|
5
|
+
module Store
|
|
6
|
+
# In-memory session store. Suitable for development and testing.
|
|
7
|
+
# Data does not persist across process restarts.
|
|
8
|
+
# Thread-safe via Mutex for concurrent access.
|
|
9
|
+
class Memory < Base
|
|
10
|
+
def initialize
|
|
11
|
+
@store = {}
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def save(session)
|
|
16
|
+
@mutex.synchronize { @store[session.id] = session }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def load(id)
|
|
20
|
+
@mutex.synchronize { @store[id] }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def delete(id)
|
|
24
|
+
@mutex.synchronize { @store.delete(id) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def exists?(id)
|
|
28
|
+
@mutex.synchronize { @store.key?(id) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def size
|
|
32
|
+
@mutex.synchronize { @store.size }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clear!
|
|
36
|
+
@mutex.synchronize { @store.clear }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ids
|
|
40
|
+
@mutex.synchronize { @store.keys.dup }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|