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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +38 -0
  3. data/LICENSE +21 -0
  4. data/README.md +211 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/xeno/application.css +15 -0
  7. data/app/controllers/xeno/api_controller.rb +68 -0
  8. data/app/controllers/xeno/application_controller.rb +4 -0
  9. data/app/controllers/xeno/dev_controller.rb +24 -0
  10. data/app/controllers/xeno/dev_ui_controller.rb +71 -0
  11. data/app/controllers/xeno/health_controller.rb +10 -0
  12. data/app/controllers/xeno/sessions_controller.rb +131 -0
  13. data/app/controllers/xeno/slack_controller.rb +48 -0
  14. data/app/controllers/xeno/streams_controller.rb +122 -0
  15. data/app/helpers/xeno/application_helper.rb +4 -0
  16. data/app/jobs/xeno/application_job.rb +4 -0
  17. data/app/jobs/xeno/reaper_job.rb +12 -0
  18. data/app/jobs/xeno/schedule_job.rb +56 -0
  19. data/app/jobs/xeno/slack_event_job.rb +20 -0
  20. data/app/jobs/xeno/turn_job.rb +16 -0
  21. data/app/mailers/xeno/application_mailer.rb +6 -0
  22. data/app/models/xeno/action.rb +26 -0
  23. data/app/models/xeno/application_record.rb +5 -0
  24. data/app/models/xeno/chat.rb +22 -0
  25. data/app/models/xeno/dedup.rb +24 -0
  26. data/app/models/xeno/event.rb +63 -0
  27. data/app/models/xeno/message.rb +5 -0
  28. data/app/models/xeno/pending_message.rb +7 -0
  29. data/app/models/xeno/session.rb +231 -0
  30. data/app/models/xeno/turn.rb +125 -0
  31. data/app/views/layouts/xeno/application.html.erb +18 -0
  32. data/app/views/xeno/dev_ui/_styles.html.erb +24 -0
  33. data/app/views/xeno/dev_ui/index.html.erb +28 -0
  34. data/app/views/xeno/dev_ui/show.html.erb +115 -0
  35. data/config/routes.rb +25 -0
  36. data/db/migrate/20260804000001_create_xeno_llm_tables.rb +70 -0
  37. data/db/migrate/20260804000002_create_xeno_orchestration_tables.rb +70 -0
  38. data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +8 -0
  39. data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +8 -0
  40. data/db/migrate/20260805000003_create_xeno_dedups.rb +14 -0
  41. data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +9 -0
  42. data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +8 -0
  43. data/db/migrate/20260806000001_move_transcript_support_tables_to_ruby_llm.rb +133 -0
  44. data/docs/runtime.md +275 -0
  45. data/exe/xeno +133 -0
  46. data/lib/generators/xeno/install/install_generator.rb +51 -0
  47. data/lib/generators/xeno/install/templates/agent.rb +4 -0
  48. data/lib/generators/xeno/install/templates/initializer.rb +20 -0
  49. data/lib/generators/xeno/install/templates/instructions.md +6 -0
  50. data/lib/generators/xeno/tool/templates/tool.rb.tt +16 -0
  51. data/lib/generators/xeno/tool/tool_generator.rb +13 -0
  52. data/lib/tasks/xeno_tasks.rake +24 -0
  53. data/lib/xeno/agent_config.rb +66 -0
  54. data/lib/xeno/agent_definition.rb +286 -0
  55. data/lib/xeno/approval_context.rb +4 -0
  56. data/lib/xeno/arguments.rb +62 -0
  57. data/lib/xeno/ask_question.rb +18 -0
  58. data/lib/xeno/channels/slack.rb +311 -0
  59. data/lib/xeno/channels.rb +68 -0
  60. data/lib/xeno/compaction.rb +165 -0
  61. data/lib/xeno/configuration.rb +118 -0
  62. data/lib/xeno/engine.rb +29 -0
  63. data/lib/xeno/errors.rb +40 -0
  64. data/lib/xeno/hooks.rb +37 -0
  65. data/lib/xeno/info.rb +75 -0
  66. data/lib/xeno/inputs.rb +78 -0
  67. data/lib/xeno/reaper.rb +52 -0
  68. data/lib/xeno/schedules.rb +49 -0
  69. data/lib/xeno/session_state.rb +57 -0
  70. data/lib/xeno/standalone/local_secret.rb +26 -0
  71. data/lib/xeno/standalone/model_refresh.rb +26 -0
  72. data/lib/xeno/standalone/puma.rb +17 -0
  73. data/lib/xeno/standalone.rb +136 -0
  74. data/lib/xeno/tool.rb +73 -0
  75. data/lib/xeno/turn_runner.rb +545 -0
  76. data/lib/xeno/version.rb +3 -0
  77. data/lib/xeno.rb +117 -0
  78. 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