xeno 0.0.1
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/CHANGELOG.md +38 -0
- data/LICENSE +21 -0
- data/README.md +211 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/xeno/application.css +15 -0
- data/app/controllers/xeno/api_controller.rb +68 -0
- data/app/controllers/xeno/application_controller.rb +4 -0
- data/app/controllers/xeno/dev_controller.rb +24 -0
- data/app/controllers/xeno/dev_ui_controller.rb +71 -0
- data/app/controllers/xeno/health_controller.rb +10 -0
- data/app/controllers/xeno/sessions_controller.rb +131 -0
- data/app/controllers/xeno/slack_controller.rb +48 -0
- data/app/controllers/xeno/streams_controller.rb +122 -0
- data/app/helpers/xeno/application_helper.rb +4 -0
- data/app/jobs/xeno/application_job.rb +4 -0
- data/app/jobs/xeno/reaper_job.rb +12 -0
- data/app/jobs/xeno/schedule_job.rb +56 -0
- data/app/jobs/xeno/slack_event_job.rb +20 -0
- data/app/jobs/xeno/turn_job.rb +16 -0
- data/app/mailers/xeno/application_mailer.rb +6 -0
- data/app/models/xeno/action.rb +26 -0
- data/app/models/xeno/application_record.rb +5 -0
- data/app/models/xeno/chat.rb +22 -0
- data/app/models/xeno/dedup.rb +24 -0
- data/app/models/xeno/event.rb +63 -0
- data/app/models/xeno/message.rb +5 -0
- data/app/models/xeno/pending_message.rb +7 -0
- data/app/models/xeno/session.rb +231 -0
- data/app/models/xeno/turn.rb +125 -0
- data/app/views/layouts/xeno/application.html.erb +18 -0
- data/app/views/xeno/dev_ui/_styles.html.erb +24 -0
- data/app/views/xeno/dev_ui/index.html.erb +28 -0
- data/app/views/xeno/dev_ui/show.html.erb +115 -0
- data/config/routes.rb +25 -0
- data/db/migrate/20260804000001_create_xeno_llm_tables.rb +70 -0
- data/db/migrate/20260804000002_create_xeno_orchestration_tables.rb +70 -0
- data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +8 -0
- data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +8 -0
- data/db/migrate/20260805000003_create_xeno_dedups.rb +14 -0
- data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +9 -0
- data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +8 -0
- data/db/migrate/20260806000001_move_transcript_support_tables_to_ruby_llm.rb +133 -0
- data/docs/runtime.md +275 -0
- data/exe/xeno +133 -0
- data/lib/generators/xeno/install/install_generator.rb +51 -0
- data/lib/generators/xeno/install/templates/agent.rb +4 -0
- data/lib/generators/xeno/install/templates/initializer.rb +20 -0
- data/lib/generators/xeno/install/templates/instructions.md +6 -0
- data/lib/generators/xeno/tool/templates/tool.rb.tt +16 -0
- data/lib/generators/xeno/tool/tool_generator.rb +13 -0
- data/lib/tasks/xeno_tasks.rake +24 -0
- data/lib/xeno/agent_config.rb +66 -0
- data/lib/xeno/agent_definition.rb +286 -0
- data/lib/xeno/approval_context.rb +4 -0
- data/lib/xeno/arguments.rb +62 -0
- data/lib/xeno/ask_question.rb +18 -0
- data/lib/xeno/channels/slack.rb +311 -0
- data/lib/xeno/channels.rb +68 -0
- data/lib/xeno/compaction.rb +165 -0
- data/lib/xeno/configuration.rb +118 -0
- data/lib/xeno/engine.rb +29 -0
- data/lib/xeno/errors.rb +40 -0
- data/lib/xeno/hooks.rb +37 -0
- data/lib/xeno/info.rb +75 -0
- data/lib/xeno/inputs.rb +78 -0
- data/lib/xeno/reaper.rb +52 -0
- data/lib/xeno/schedules.rb +49 -0
- data/lib/xeno/session_state.rb +57 -0
- data/lib/xeno/standalone/local_secret.rb +26 -0
- data/lib/xeno/standalone/model_refresh.rb +26 -0
- data/lib/xeno/standalone/puma.rb +17 -0
- data/lib/xeno/standalone.rb +136 -0
- data/lib/xeno/tool.rb +73 -0
- data/lib/xeno/turn_runner.rb +545 -0
- data/lib/xeno/version.rb +3 -0
- data/lib/xeno.rb +117 -0
- metadata +151 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Xeno
|
|
4
|
+
# Compiles agent/schedules/*.md into Solid Queue recurring entries —
|
|
5
|
+
# compile-to-the-queue's-native-scheduler, not a dispatcher. No polling
|
|
6
|
+
# dispatcher: the queue's own recurring machinery fires ScheduleJob.
|
|
7
|
+
#
|
|
8
|
+
# Only keys under the managed prefix are touched; hand-written recurring
|
|
9
|
+
# entries survive a sync. Dev servers never fire user schedules on
|
|
10
|
+
# cadence — those are written for every environment section EXCEPT
|
|
11
|
+
# development, where the dispatch endpoint triggers by name. The REAPER
|
|
12
|
+
# entry is different: it's runtime infrastructure (turns with no live
|
|
13
|
+
# owner), so it goes into every section including development.
|
|
14
|
+
module Schedules
|
|
15
|
+
MANAGED_PREFIX = "xeno_".freeze
|
|
16
|
+
REAPER_KEY = "#{MANAGED_PREFIX}runtime_reaper".freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def sync!(definition: Xeno.definition, path: Rails.root.join("config", "recurring.yml"))
|
|
21
|
+
existing = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
|
|
22
|
+
environments = (existing.keys.presence || %w[production]) | %w[development]
|
|
23
|
+
|
|
24
|
+
environments.each do |env|
|
|
25
|
+
section = (existing[env] ||= {})
|
|
26
|
+
section.delete_if { |key, _| key.start_with?(MANAGED_PREFIX) }
|
|
27
|
+
|
|
28
|
+
unless env == "development"
|
|
29
|
+
definition.schedules.each do |name, schedule|
|
|
30
|
+
section["#{MANAGED_PREFIX}#{name}"] = {
|
|
31
|
+
"class" => "Xeno::ScheduleJob",
|
|
32
|
+
"args" => [ name ],
|
|
33
|
+
"schedule" => schedule.cron
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
section[REAPER_KEY] = reaper_entry
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
File.write(path, YAML.dump(existing))
|
|
42
|
+
existing
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def reaper_entry
|
|
46
|
+
{ "class" => "Xeno::ReaperJob", "schedule" => "every minute" }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# The per-session KV store: working state for
|
|
3
|
+
# ONE conversation. JSON-typed — values round-trip through JSON, so
|
|
4
|
+
# symbols become strings and anything unserializable is rejected up
|
|
5
|
+
# front. Reads always hit the database (a tool in a fresh worker sees
|
|
6
|
+
# what the last worker wrote); writes take the session row lock, so
|
|
7
|
+
# read-modify-write merges never lose entries. Cleared by `reset`; it is
|
|
8
|
+
# NOT long-term memory — durable knowledge belongs in your own models.
|
|
9
|
+
class SessionState
|
|
10
|
+
def initialize(session)
|
|
11
|
+
@session = session
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get(key)
|
|
15
|
+
current[key.to_s]
|
|
16
|
+
end
|
|
17
|
+
alias [] get
|
|
18
|
+
|
|
19
|
+
def to_h
|
|
20
|
+
current
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Merges the entries in one locked write. Keys stringify; values must
|
|
24
|
+
# be JSON-serializable (Xeno::Error otherwise).
|
|
25
|
+
def update(entries)
|
|
26
|
+
normalized = normalize(entries)
|
|
27
|
+
@session.with_lock do
|
|
28
|
+
@session.update!(state: (@session[:state] || {}).merge(normalized))
|
|
29
|
+
end
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def set(key, value)
|
|
34
|
+
update(key => value)
|
|
35
|
+
end
|
|
36
|
+
alias []= set
|
|
37
|
+
|
|
38
|
+
def delete(key)
|
|
39
|
+
@session.with_lock do
|
|
40
|
+
@session.update!(state: (@session[:state] || {}).except(key.to_s))
|
|
41
|
+
end
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def current
|
|
48
|
+
Session.where(id: @session.id).pick(:state) || {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def normalize(entries)
|
|
52
|
+
JSON.parse(JSON.generate(entries.to_h))
|
|
53
|
+
rescue TypeError, JSON::GeneratorError, NoMethodError => e
|
|
54
|
+
raise Xeno::Error, "session state values must be JSON-serializable: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Xeno
|
|
5
|
+
module Standalone
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# A persisted local secret so sessions survive restarts without asking
|
|
9
|
+
# anyone to manage SECRET_KEY_BASE for a dev-grade standalone app.
|
|
10
|
+
# Owner-only (0600) — it signs cookies/sessions (S6); pre-existing
|
|
11
|
+
# looser files are tightened on read.
|
|
12
|
+
#
|
|
13
|
+
# Lives apart from xeno/standalone so tests can exercise it without
|
|
14
|
+
# defining the Rails::Application (which self-registers on inheritance).
|
|
15
|
+
def local_secret(root)
|
|
16
|
+
path = root.join("storage", ".local_secret")
|
|
17
|
+
FileUtils.mkdir_p(path.dirname)
|
|
18
|
+
if path.file?
|
|
19
|
+
File.chmod(0o600, path)
|
|
20
|
+
return path.read.strip
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
SecureRandom.hex(64).tap { |secret| File.write(path, secret, perm: 0o600) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
module Standalone
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
# First boot only (the registry table is empty): pull the live model
|
|
6
|
+
# catalog from the configured providers so current model ids resolve
|
|
7
|
+
# without a manual RubyLLM.models.refresh!. Loading the definition first
|
|
8
|
+
# runs agent.rb's RubyLLM.configure — the refresh needs those provider
|
|
9
|
+
# keys. Never fatal, never repeated once the table has rows; skippable
|
|
10
|
+
# with XENO_SKIP_MODEL_REFRESH=1 (deterministic scripts, offline boots).
|
|
11
|
+
def refresh_models!
|
|
12
|
+
return if ENV["XENO_SKIP_MODEL_REFRESH"]
|
|
13
|
+
|
|
14
|
+
Xeno.definition
|
|
15
|
+
store = RubyLLM.config.model_registry_store
|
|
16
|
+
return unless store.respond_to?(:none?) && store.none?
|
|
17
|
+
|
|
18
|
+
Rails.logger.info "xeno: first boot — refreshing the model registry"
|
|
19
|
+
RubyLLM.models.refresh!
|
|
20
|
+
Rails.logger.info "xeno: model registry ready (#{store.count} models)"
|
|
21
|
+
rescue StandardError => error
|
|
22
|
+
Rails.logger.warn "xeno: model registry refresh failed (#{error.class}: #{error.message}). " \
|
|
23
|
+
"Continuing; run RubyLLM.models.refresh! manually if a model fails to resolve."
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Gem-owned puma config for standalone mode (`xeno server`): the whole
|
|
2
|
+
# stack — web + Solid Queue supervisor — inside one Puma process.
|
|
3
|
+
threads_count = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
|
|
4
|
+
threads threads_count, threads_count
|
|
5
|
+
|
|
6
|
+
# Loopback by default: development runs with the dev auth bypass, and an
|
|
7
|
+
# unauthenticated dev UI must not be exposed to the LAN. Opt out with
|
|
8
|
+
# HOST=0.0.0.0 for containers/proxies that need it.
|
|
9
|
+
bind "tcp://#{ENV.fetch("HOST", "127.0.0.1")}:#{ENV.fetch("PORT", 3000)}"
|
|
10
|
+
|
|
11
|
+
plugin :solid_queue if ENV.fetch("SOLID_QUEUE_IN_PUMA", "1") != "0"
|
|
12
|
+
|
|
13
|
+
# Ctrl-C must win. Live SSE streams (the dev UI's EventSource) are in-flight
|
|
14
|
+
# requests, and Puma's default is to wait for in-flight requests :forever —
|
|
15
|
+
# an open browser tab made shutdown hang (found by the first manual run).
|
|
16
|
+
# Hard-stop leftover streams after 5s; xeno's state is rows, nothing is lost.
|
|
17
|
+
force_shutdown_after ENV.fetch("XENO_FORCE_SHUTDOWN_AFTER", 5).to_i
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Standalone mode: Rails as an implementation detail. A scaffolded app is
|
|
2
|
+
# a handful of visible files (agent/, Gemfile, config.ru, .gitignore); the
|
|
3
|
+
# entire Rails::Application lives HERE, inside the gem.
|
|
4
|
+
#
|
|
5
|
+
# # config.ru
|
|
6
|
+
# require "xeno/standalone"
|
|
7
|
+
# run Xeno.rails_app
|
|
8
|
+
#
|
|
9
|
+
# SQLite + Solid Queue inside Puma (`xeno server`) make it one process with
|
|
10
|
+
# zero external services; migrations run at boot.
|
|
11
|
+
require "rails"
|
|
12
|
+
require "active_model/railtie"
|
|
13
|
+
require "active_record/railtie"
|
|
14
|
+
require "active_job/railtie"
|
|
15
|
+
require "action_controller/railtie"
|
|
16
|
+
require "action_view/railtie"
|
|
17
|
+
|
|
18
|
+
# A config.ru-only app has no Bundler.require(*Rails.groups); pull in what
|
|
19
|
+
# the scaffolded Gemfile provides.
|
|
20
|
+
require "solid_queue"
|
|
21
|
+
require "sqlite3"
|
|
22
|
+
|
|
23
|
+
require "xeno"
|
|
24
|
+
require "xeno/schedules"
|
|
25
|
+
require "xeno/standalone/local_secret"
|
|
26
|
+
require "xeno/standalone/model_refresh"
|
|
27
|
+
require "yaml"
|
|
28
|
+
|
|
29
|
+
module Xeno
|
|
30
|
+
class StandaloneApplication < Rails::Application
|
|
31
|
+
APP_ROOT = Pathname(Dir.pwd)
|
|
32
|
+
|
|
33
|
+
config.root = APP_ROOT
|
|
34
|
+
config.load_defaults Rails::VERSION::STRING.to_f
|
|
35
|
+
config.eager_load = ENV["RAILS_ENV"] == "production"
|
|
36
|
+
config.consider_all_requests_local = ENV["RAILS_ENV"] != "production"
|
|
37
|
+
config.api_only = false
|
|
38
|
+
config.logger = ActiveSupport::TaggedLogging.logger($stdout)
|
|
39
|
+
config.log_level = ENV.fetch("LOG_LEVEL", "info")
|
|
40
|
+
|
|
41
|
+
config.active_record.dump_schema_after_migration = false
|
|
42
|
+
config.active_job.queue_adapter = :solid_queue
|
|
43
|
+
|
|
44
|
+
# One durable file under storage/ for everything: app rows + queue.
|
|
45
|
+
config.paths["db/migrate"] = [ Xeno::Engine.root.join("db", "migrate").to_s ]
|
|
46
|
+
|
|
47
|
+
def config.database_configuration
|
|
48
|
+
{
|
|
49
|
+
Rails.env => {
|
|
50
|
+
"primary" => {
|
|
51
|
+
"adapter" => "sqlite3",
|
|
52
|
+
"database" => ENV.fetch("XENO_DATABASE", "storage/xeno_#{Rails.env}.sqlite3"),
|
|
53
|
+
"pool" => ENV.fetch("RAILS_MAX_THREADS", 10).to_i,
|
|
54
|
+
# Busy handler: web + Solid Queue's forked dispatcher/worker share this
|
|
55
|
+
# one file; without a timeout the sqlite3 adapter does NOT wait for
|
|
56
|
+
# locks ("default: no wait") and every collision raises
|
|
57
|
+
# SQLite3::BusyException. Same 5s default rails new generates.
|
|
58
|
+
"timeout" => ENV.fetch("XENO_DATABASE_TIMEOUT", 5000).to_i
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
config.secret_key_base = ENV["SECRET_KEY_BASE"] || Xeno::Standalone.local_secret(APP_ROOT)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
module Standalone
|
|
68
|
+
module_function
|
|
69
|
+
|
|
70
|
+
# Boot: initialize the app, then bring the database up to date — the
|
|
71
|
+
# engine's migrations plus Solid Queue's tables (loaded straight from
|
|
72
|
+
# the solid_queue gem's install template).
|
|
73
|
+
def boot!
|
|
74
|
+
ENV["RAILS_ENV"] ||= "development"
|
|
75
|
+
FileUtils.mkdir_p(StandaloneApplication::APP_ROOT.join("storage"))
|
|
76
|
+
|
|
77
|
+
app = Rails.application || StandaloneApplication.instance
|
|
78
|
+
unless app.initialized?
|
|
79
|
+
app.initialize!
|
|
80
|
+
app.routes.draw do
|
|
81
|
+
mount Xeno::Engine => "/agent"
|
|
82
|
+
root to: redirect("/agent/dev")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
migrate!
|
|
87
|
+
refresh_models!
|
|
88
|
+
write_recurring_config!
|
|
89
|
+
Rails.application
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Solid Queue's recurring machinery is how the reaper (always) and the
|
|
93
|
+
# compiled schedules (outside development) fire in standalone mode. The
|
|
94
|
+
# file lives under storage/ (gitignored) so the scaffold stays four
|
|
95
|
+
# visible files; the env var — read by Solid Queue's supervisor, which
|
|
96
|
+
# starts after config.ru has loaded — points the scheduler at it.
|
|
97
|
+
def write_recurring_config!
|
|
98
|
+
section = { Xeno::Schedules::REAPER_KEY => Xeno::Schedules.reaper_entry }
|
|
99
|
+
unless Rails.env.development?
|
|
100
|
+
Xeno.definition.schedules.each do |name, schedule|
|
|
101
|
+
section["#{Xeno::Schedules::MANAGED_PREFIX}#{name}"] = {
|
|
102
|
+
"class" => "Xeno::ScheduleJob", "args" => [ name ], "schedule" => schedule.cron
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
path = StandaloneApplication::APP_ROOT.join("storage", "recurring.yml")
|
|
108
|
+
File.write(path, YAML.dump({ Rails.env.to_s => section }))
|
|
109
|
+
ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] ||= File.join("storage", "recurring.yml")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def migrate!
|
|
113
|
+
ActiveRecord::MigrationContext.new([ Xeno::Engine.root.join("db", "migrate").to_s ]).migrate
|
|
114
|
+
|
|
115
|
+
connection = ActiveRecord::Base.lease_connection
|
|
116
|
+
unless connection.table_exists?("solid_queue_jobs")
|
|
117
|
+
load queue_schema_path
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def queue_schema_path
|
|
122
|
+
spec = Gem::Specification.find_by_name("solid_queue")
|
|
123
|
+
File.join(spec.gem_dir, "lib", "generators", "solid_queue", "install", "templates", "db", "queue_schema.rb")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The gem-owned puma config used by `xeno server` — Solid Queue runs
|
|
127
|
+
# inside Puma (SOLID_QUEUE_IN_PUMA), one process total.
|
|
128
|
+
def puma_config_path
|
|
129
|
+
File.expand_path("standalone/puma.rb", __dir__)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.rails_app
|
|
134
|
+
Standalone.boot!
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/xeno/tool.rb
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# Base class for agent tools. Anyone who knows RubyLLM already knows how
|
|
3
|
+
# to write these; xeno adds discovery and (later) the approval macro and
|
|
4
|
+
# session context.
|
|
5
|
+
#
|
|
6
|
+
# # agent/tools/get_weather.rb
|
|
7
|
+
# class Xeno::Tools::GetWeather < Xeno::Tool
|
|
8
|
+
# description "Return current weather for a city."
|
|
9
|
+
# parameter :city, description: "City name"
|
|
10
|
+
#
|
|
11
|
+
# def execute(city:)
|
|
12
|
+
# { city: city, condition: "Sunny" }
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
class Tool < RubyLLM::Tool
|
|
17
|
+
class << self
|
|
18
|
+
# The runtime name the model calls this tool by. The path supplies the
|
|
19
|
+
# name: Zeitwerk guarantees agent/tools/get_weather.rb
|
|
20
|
+
# defines Xeno::Tools::GetWeather, so the demodulized class name and
|
|
21
|
+
# the file basename are the same thing. No suffix-stripping: a
|
|
22
|
+
# `charge_card_tool.rb` must be callable as `charge_card_tool`, or the
|
|
23
|
+
# runtime's slug-keyed lookup misses and the approval gate is skipped.
|
|
24
|
+
def tool_name
|
|
25
|
+
name.demodulize.underscore
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The human-in-the-loop gate. Anything irreversible or externally
|
|
29
|
+
# visible should be gated — approvals are the guardrail in an
|
|
30
|
+
# in-app-tools trust model.
|
|
31
|
+
#
|
|
32
|
+
# approval :never # default: runs without asking
|
|
33
|
+
# approval :once # asks the first time in a session, then remembered
|
|
34
|
+
# approval :always # asks on every call
|
|
35
|
+
# approval ->(ctx) { ctx.principal&.dig("role") != "admin" }
|
|
36
|
+
#
|
|
37
|
+
# The lambda receives an ApprovalContext (session, turn, tool_name,
|
|
38
|
+
# arguments, principal); truthy means approval is required.
|
|
39
|
+
def approval(policy = nil)
|
|
40
|
+
@approval = policy unless policy.nil?
|
|
41
|
+
return @approval if defined?(@approval) && @approval
|
|
42
|
+
|
|
43
|
+
superclass.respond_to?(:approval) ? superclass.approval : :never
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# RubyLLM derives the wire name from the full class name, which would
|
|
48
|
+
# leak the namespace (xeno/tools/get_weather). Use the path-derived name.
|
|
49
|
+
def name
|
|
50
|
+
self.class.tool_name
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The session this call is running in — set by the runner before
|
|
54
|
+
# `call`. nil when the tool is exercised outside a session (unit tests
|
|
55
|
+
# calling `Tool.new.call` directly).
|
|
56
|
+
attr_accessor :session
|
|
57
|
+
|
|
58
|
+
# The session-scoped KV store: JSON-typed values that survive restarts,
|
|
59
|
+
# never cross sessions, and are cleared by reset. Working state, not
|
|
60
|
+
# long-term memory.
|
|
61
|
+
#
|
|
62
|
+
# def execute(city:)
|
|
63
|
+
# searches = state.get("searches").to_i + 1
|
|
64
|
+
# state.update("searches" => searches, "last_city" => city)
|
|
65
|
+
# ...
|
|
66
|
+
# end
|
|
67
|
+
def state
|
|
68
|
+
raise Xeno::Error, "session state is only available while running inside a session" unless session
|
|
69
|
+
|
|
70
|
+
session.state
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|