spurline-core 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/LICENSE +21 -0
- data/README.md +177 -0
- data/exe/spur +6 -0
- data/lib/CLAUDE.md +11 -0
- data/lib/spurline/CLAUDE.md +16 -0
- data/lib/spurline/adapters/CLAUDE.md +12 -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/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +18 -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/CLAUDE.md +12 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +12 -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/CLAUDE.md +12 -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 +333 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module CLI
|
|
7
|
+
module Generators
|
|
8
|
+
# Generates a new tool class file.
|
|
9
|
+
# Usage: spur generate tool web_scraper
|
|
10
|
+
class Tool
|
|
11
|
+
attr_reader :name
|
|
12
|
+
|
|
13
|
+
def initialize(name:)
|
|
14
|
+
@name = name.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate!
|
|
18
|
+
path = File.join("app", "tools", "#{snake_name}.rb")
|
|
19
|
+
|
|
20
|
+
if File.exist?(path)
|
|
21
|
+
$stderr.puts "File already exists: #{path}"
|
|
22
|
+
exit 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
26
|
+
File.write(path, tool_template)
|
|
27
|
+
puts " create #{path}"
|
|
28
|
+
|
|
29
|
+
spec_path = File.join("spec", "tools", "#{snake_name}_spec.rb")
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(spec_path))
|
|
31
|
+
File.write(spec_path, spec_template)
|
|
32
|
+
puts " create #{spec_path}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def tool_template
|
|
38
|
+
<<~RUBY
|
|
39
|
+
# frozen_string_literal: true
|
|
40
|
+
|
|
41
|
+
class #{class_name} < Spurline::Tools::Base
|
|
42
|
+
tool_name :#{snake_name}
|
|
43
|
+
description "TODO: Describe what #{snake_name} does"
|
|
44
|
+
parameters({
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
input: { type: "string", description: "TODO: describe input" },
|
|
48
|
+
},
|
|
49
|
+
required: %w[input],
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
def call(input:)
|
|
53
|
+
# TODO: Implement #{snake_name}
|
|
54
|
+
raise NotImplementedError, "#{class_name}#call not yet implemented"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
RUBY
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def spec_template
|
|
61
|
+
<<~RUBY
|
|
62
|
+
# frozen_string_literal: true
|
|
63
|
+
|
|
64
|
+
require_relative "../../app/tools/#{snake_name}"
|
|
65
|
+
|
|
66
|
+
RSpec.describe #{class_name} do
|
|
67
|
+
let(:tool) { described_class.new }
|
|
68
|
+
|
|
69
|
+
describe "#call" do
|
|
70
|
+
it "executes the tool" do
|
|
71
|
+
# TODO: Write tests for #{snake_name}
|
|
72
|
+
pending "implement #{class_name}#call first"
|
|
73
|
+
result = tool.call(input: "test")
|
|
74
|
+
expect(result).not_to be_nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
RUBY
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def class_name
|
|
82
|
+
name.to_s
|
|
83
|
+
.gsub(/[-_]/, " ")
|
|
84
|
+
.split(" ")
|
|
85
|
+
.map(&:capitalize)
|
|
86
|
+
.join
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def snake_name
|
|
90
|
+
name.to_s
|
|
91
|
+
.gsub(/([a-z])([A-Z])/, '\1_\2')
|
|
92
|
+
.gsub(/[-\s]/, "_")
|
|
93
|
+
.downcase
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module CLI
|
|
5
|
+
# Routes CLI commands to the appropriate handler.
|
|
6
|
+
# Entry point: Router.run(ARGV)
|
|
7
|
+
class Router
|
|
8
|
+
COMMANDS = {
|
|
9
|
+
"new" => :handle_new,
|
|
10
|
+
"generate" => :handle_generate,
|
|
11
|
+
"check" => :handle_check,
|
|
12
|
+
"console" => :handle_console,
|
|
13
|
+
"credentials:edit" => :handle_credentials_edit,
|
|
14
|
+
"version" => :handle_version,
|
|
15
|
+
"help" => :handle_help,
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
GENERATE_SUBCOMMANDS = %w[agent tool migration].freeze
|
|
19
|
+
|
|
20
|
+
def self.run(args)
|
|
21
|
+
new(args).dispatch
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(args)
|
|
25
|
+
@args = args
|
|
26
|
+
@command = args.first
|
|
27
|
+
@rest = args[1..] || []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def dispatch
|
|
31
|
+
if @command.nil? || @command == "help" || @command == "--help" || @command == "-h"
|
|
32
|
+
handle_help
|
|
33
|
+
elsif @command == "version" || @command == "--version" || @command == "-v"
|
|
34
|
+
handle_version
|
|
35
|
+
elsif COMMANDS.key?(@command)
|
|
36
|
+
send(COMMANDS[@command])
|
|
37
|
+
else
|
|
38
|
+
$stderr.puts "Unknown command: #{@command}"
|
|
39
|
+
$stderr.puts "Run 'spur help' for available commands."
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def handle_new
|
|
47
|
+
project_name = @rest.first
|
|
48
|
+
unless project_name
|
|
49
|
+
$stderr.puts "Usage: spur new <project_name>"
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Generators::Project.new(name: project_name).generate!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def handle_generate
|
|
57
|
+
subcommand = @rest.first
|
|
58
|
+
name = @rest[1]
|
|
59
|
+
|
|
60
|
+
unless subcommand && GENERATE_SUBCOMMANDS.include?(subcommand)
|
|
61
|
+
$stderr.puts "Usage: spur generate <#{GENERATE_SUBCOMMANDS.join("|")}> <name>"
|
|
62
|
+
exit 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
unless name
|
|
66
|
+
$stderr.puts "Usage: spur generate #{subcommand} <name>"
|
|
67
|
+
exit 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
case subcommand
|
|
71
|
+
when "agent"
|
|
72
|
+
Generators::Agent.new(name: name).generate!
|
|
73
|
+
when "tool"
|
|
74
|
+
Generators::Tool.new(name: name).generate!
|
|
75
|
+
when "migration"
|
|
76
|
+
Generators::Migration.new(name: name).generate!
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def handle_version
|
|
81
|
+
puts "spur #{Spurline::VERSION}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def handle_check
|
|
85
|
+
verbose = @rest.include?("--verbose") || @rest.include?("-v")
|
|
86
|
+
results = Check.new(project_root: Dir.pwd, verbose: verbose).run!
|
|
87
|
+
failures = results.count { |result| result.status == :fail }
|
|
88
|
+
exit(failures.positive? ? 1 : 0)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def handle_console
|
|
92
|
+
verbose = @rest.include?("--verbose") || @rest.include?("-v")
|
|
93
|
+
Console.new(project_root: Dir.pwd, verbose: verbose).start!
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def handle_credentials_edit
|
|
97
|
+
Credentials.new(project_root: Dir.pwd).edit!
|
|
98
|
+
puts "Saved encrypted credentials to config/credentials.enc.yml"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def handle_help
|
|
102
|
+
puts <<~HELP
|
|
103
|
+
spur — Spurline CLI
|
|
104
|
+
|
|
105
|
+
Commands:
|
|
106
|
+
spur new <project> Create a new Spurline agent project
|
|
107
|
+
spur generate agent <name> Generate a new agent class
|
|
108
|
+
spur generate tool <name> Generate a new tool class
|
|
109
|
+
spur generate migration <name> Generate a SQL migration (e.g. sessions)
|
|
110
|
+
spur check Validate project configuration
|
|
111
|
+
spur console Interactive REPL with project loaded
|
|
112
|
+
spur credentials:edit Edit encrypted credentials
|
|
113
|
+
spur version Show version
|
|
114
|
+
spur help Show this help
|
|
115
|
+
|
|
116
|
+
https://github.com/dylanwilcox/spurline
|
|
117
|
+
HELP
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-configurable"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
class Configuration
|
|
7
|
+
extend Dry::Configurable
|
|
8
|
+
|
|
9
|
+
setting :session_store, default: :memory
|
|
10
|
+
setting :session_store_path, default: "tmp/spurline_sessions.db"
|
|
11
|
+
setting :session_store_postgres_url, default: nil
|
|
12
|
+
setting :default_model, default: :claude_sonnet
|
|
13
|
+
setting :log_level, default: :info
|
|
14
|
+
setting :audit_mode, default: :full
|
|
15
|
+
setting :audit_max_entries, default: nil
|
|
16
|
+
setting :idempotency_default_ttl, default: 86_400
|
|
17
|
+
setting :permissions_file, default: "config/permissions.yml"
|
|
18
|
+
setting :brave_api_key, default: nil
|
|
19
|
+
setting :cartographer_exclude_patterns, default: %w[
|
|
20
|
+
.git node_modules vendor tmp log coverage
|
|
21
|
+
]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<claude-mem-context>
|
|
2
|
+
# Recent Activity
|
|
3
|
+
|
|
4
|
+
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
|
5
|
+
|
|
6
|
+
### Feb 21, 2026
|
|
7
|
+
|
|
8
|
+
| ID | Time | T | Title | Read |
|
|
9
|
+
|----|------|---|-------|------|
|
|
10
|
+
| #3782 | 10:23 PM | 🔵 | Spurline session and state machine architecture analyzed for suspended sessions feature | ~700 |
|
|
11
|
+
</claude-mem-context>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module DSL
|
|
5
|
+
# DSL for configuring security guardrails.
|
|
6
|
+
# Registers configuration at class load time — never executes behavior.
|
|
7
|
+
# Misconfiguration raises ConfigurationError at class load time.
|
|
8
|
+
module Guardrails
|
|
9
|
+
def self.included(base)
|
|
10
|
+
base.extend(ClassMethods)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module ClassMethods
|
|
14
|
+
def guardrails(&block)
|
|
15
|
+
@guardrail_config ||= GuardrailConfig.new
|
|
16
|
+
@guardrail_config.instance_eval(&block)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def guardrail_config
|
|
20
|
+
own = @guardrail_config
|
|
21
|
+
if own
|
|
22
|
+
own
|
|
23
|
+
elsif superclass.respond_to?(:guardrail_config)
|
|
24
|
+
superclass.guardrail_config
|
|
25
|
+
else
|
|
26
|
+
GuardrailConfig.new
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class GuardrailConfig
|
|
32
|
+
INJECTION_LEVELS = %i[strict moderate permissive].freeze
|
|
33
|
+
PII_MODES = %i[redact block warn off].freeze
|
|
34
|
+
AUDIT_MODES = %i[full errors_only off].freeze
|
|
35
|
+
|
|
36
|
+
attr_reader :settings
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@settings = {
|
|
40
|
+
injection_filter: :strict,
|
|
41
|
+
pii_filter: :off,
|
|
42
|
+
max_tool_calls: 10,
|
|
43
|
+
max_turns: 50,
|
|
44
|
+
audit_max_entries: nil,
|
|
45
|
+
denied_domains: [],
|
|
46
|
+
audit: :full,
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def injection_filter(level)
|
|
51
|
+
validate_inclusion!(:injection_filter, level, INJECTION_LEVELS)
|
|
52
|
+
@settings[:injection_filter] = level
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pii_filter(mode)
|
|
56
|
+
validate_inclusion!(:pii_filter, mode, PII_MODES)
|
|
57
|
+
@settings[:pii_filter] = mode
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def max_tool_calls(n)
|
|
61
|
+
validate_positive_integer!(:max_tool_calls, n)
|
|
62
|
+
@settings[:max_tool_calls] = n
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def max_turns(n)
|
|
66
|
+
validate_positive_integer!(:max_turns, n)
|
|
67
|
+
@settings[:max_turns] = n
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def audit_max_entries(n)
|
|
71
|
+
validate_positive_integer!(:audit_max_entries, n)
|
|
72
|
+
@settings[:audit_max_entries] = n
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def denied_domains(domains)
|
|
76
|
+
@settings[:denied_domains] = Array(domains)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def audit(mode)
|
|
80
|
+
validate_inclusion!(:audit, mode, AUDIT_MODES)
|
|
81
|
+
@settings[:audit] = mode
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_h
|
|
85
|
+
@settings.dup
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def validate_inclusion!(name, value, valid_values)
|
|
91
|
+
return if valid_values.include?(value)
|
|
92
|
+
|
|
93
|
+
raise Spurline::ConfigurationError,
|
|
94
|
+
"Invalid guardrail value for #{name}: #{value.inspect}. " \
|
|
95
|
+
"Must be one of: #{valid_values.map(&:inspect).join(", ")}."
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate_positive_integer!(name, value)
|
|
99
|
+
return if value.is_a?(Integer) && value.positive?
|
|
100
|
+
|
|
101
|
+
raise Spurline::ConfigurationError,
|
|
102
|
+
"Invalid guardrail value for #{name}: #{value.inspect}. " \
|
|
103
|
+
"Must be a positive integer."
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module DSL
|
|
5
|
+
# DSL for registering lifecycle event hooks.
|
|
6
|
+
# Registers configuration at class load time — never executes behavior.
|
|
7
|
+
module Hooks
|
|
8
|
+
HOOK_TYPES = %i[
|
|
9
|
+
on_start
|
|
10
|
+
on_turn_start
|
|
11
|
+
on_tool_call
|
|
12
|
+
on_turn_end
|
|
13
|
+
on_suspend
|
|
14
|
+
on_resume
|
|
15
|
+
on_finish
|
|
16
|
+
on_error
|
|
17
|
+
on_child_spawn
|
|
18
|
+
on_child_complete
|
|
19
|
+
on_child_error
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
def self.included(base)
|
|
23
|
+
base.extend(ClassMethods)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module ClassMethods
|
|
27
|
+
HOOK_TYPES.each do |hook_type|
|
|
28
|
+
define_method(hook_type) do |&block|
|
|
29
|
+
@hooks ||= {}
|
|
30
|
+
@hooks[hook_type] ||= []
|
|
31
|
+
@hooks[hook_type] << block
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def hooks_config
|
|
36
|
+
own = @hooks || {}
|
|
37
|
+
if superclass.respond_to?(:hooks_config)
|
|
38
|
+
inherited = superclass.hooks_config
|
|
39
|
+
merged = inherited.dup
|
|
40
|
+
own.each do |type, blocks|
|
|
41
|
+
merged[type] = (merged[type] || []) + blocks
|
|
42
|
+
end
|
|
43
|
+
merged
|
|
44
|
+
else
|
|
45
|
+
own
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module DSL
|
|
5
|
+
# DSL for configuring agent memory stores.
|
|
6
|
+
# Registers configuration at class load time — never executes behavior.
|
|
7
|
+
module Memory
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def memory(type, **options)
|
|
14
|
+
@memory_config ||= {}
|
|
15
|
+
@memory_config[type.to_sym] = options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Shorthand for toggling episodic memory recording.
|
|
19
|
+
#
|
|
20
|
+
# episodic false
|
|
21
|
+
# episodic true
|
|
22
|
+
#
|
|
23
|
+
def episodic(enabled = true)
|
|
24
|
+
@memory_config ||= {}
|
|
25
|
+
@memory_config[:episodic] = { enabled: !!enabled }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def memory_config
|
|
29
|
+
own = @memory_config || {}
|
|
30
|
+
if superclass.respond_to?(:memory_config)
|
|
31
|
+
superclass.memory_config.merge(own)
|
|
32
|
+
else
|
|
33
|
+
own
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module DSL
|
|
5
|
+
# DSL for configuring which LLM model an agent uses.
|
|
6
|
+
# Registers configuration at class load time — never executes behavior.
|
|
7
|
+
module Model
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def use_model(name, **options)
|
|
14
|
+
@model_config = { name: name.to_sym, **options }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def model_config
|
|
18
|
+
@model_config || (superclass.respond_to?(:model_config) ? superclass.model_config : nil)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module DSL
|
|
5
|
+
# DSL for defining agent personas (system prompts).
|
|
6
|
+
# Registers configuration at class load time — never executes behavior.
|
|
7
|
+
module Persona
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def persona(name = :default, &block)
|
|
14
|
+
@persona_configs ||= {}
|
|
15
|
+
config = PersonaConfig.new
|
|
16
|
+
config.instance_eval(&block)
|
|
17
|
+
@persona_configs[name.to_sym] = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def persona_configs
|
|
21
|
+
own = @persona_configs || {}
|
|
22
|
+
inherited = if superclass.respond_to?(:persona_configs)
|
|
23
|
+
superclass.persona_configs
|
|
24
|
+
else
|
|
25
|
+
{}
|
|
26
|
+
end
|
|
27
|
+
inherited.merge(own)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Internal config object for persona DSL blocks.
|
|
32
|
+
class PersonaConfig
|
|
33
|
+
attr_reader :system_prompt_text
|
|
34
|
+
|
|
35
|
+
def initialize
|
|
36
|
+
@system_prompt_text = ""
|
|
37
|
+
@_inject_date = false
|
|
38
|
+
@_inject_user_context = false
|
|
39
|
+
@_inject_agent_context = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def system_prompt(text)
|
|
43
|
+
@system_prompt_text = text
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# DSL setters (used inside persona blocks)
|
|
47
|
+
def inject_date(val = true)
|
|
48
|
+
@_inject_date = val
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def inject_user_context(val = true)
|
|
52
|
+
@_inject_user_context = val
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def inject_agent_context(val = true)
|
|
56
|
+
@_inject_agent_context = val
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Predicate readers (used when compiling persona config)
|
|
60
|
+
def date_injected?
|
|
61
|
+
@_inject_date
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def user_context_injected?
|
|
65
|
+
@_inject_user_context
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def agent_context_injected?
|
|
69
|
+
@_inject_agent_context
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../lifecycle/suspension_boundary"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module DSL
|
|
7
|
+
module SuspendUntil
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
# Declares a suspension condition for this agent class.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# suspend_until :tool_calls, count: 3
|
|
17
|
+
# suspend_until :custom, &block
|
|
18
|
+
def suspend_until(type = nil, **options, &block)
|
|
19
|
+
@suspension_config = { type: type, options: options, block: block }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def suspension_config
|
|
23
|
+
@suspension_config || superclass_suspension_config
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Builds a SuspensionCheck from the declarative config.
|
|
27
|
+
def build_suspension_check
|
|
28
|
+
config = suspension_config
|
|
29
|
+
return Lifecycle::SuspensionCheck.none unless config
|
|
30
|
+
|
|
31
|
+
case config[:type]
|
|
32
|
+
when :tool_calls
|
|
33
|
+
Lifecycle::SuspensionCheck.after_tool_calls(config[:options][:count])
|
|
34
|
+
when :custom
|
|
35
|
+
Lifecycle::SuspensionCheck.new(&config[:block])
|
|
36
|
+
else
|
|
37
|
+
Lifecycle::SuspensionCheck.none
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def superclass_suspension_config
|
|
44
|
+
if superclass.respond_to?(:suspension_config)
|
|
45
|
+
superclass.suspension_config
|
|
46
|
+
else
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|