xeno 0.0.1 → 0.0.2

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -2
  3. data/README.md +3 -1
  4. data/Rakefile +2 -0
  5. data/app/controllers/xeno/api_controller.rb +15 -18
  6. data/app/controllers/xeno/application_controller.rb +2 -0
  7. data/app/controllers/xeno/dev_controller.rb +5 -4
  8. data/app/controllers/xeno/dev_ui_controller.rb +5 -4
  9. data/app/controllers/xeno/health_controller.rb +3 -1
  10. data/app/controllers/xeno/sessions_controller.rb +22 -24
  11. data/app/controllers/xeno/slack_controller.rb +9 -10
  12. data/app/controllers/xeno/streams_controller.rb +18 -22
  13. data/app/helpers/xeno/application_helper.rb +2 -0
  14. data/app/jobs/xeno/application_job.rb +2 -0
  15. data/app/jobs/xeno/reaper_job.rb +5 -4
  16. data/app/jobs/xeno/schedule_job.rb +10 -13
  17. data/app/jobs/xeno/slack_event_job.rb +7 -7
  18. data/app/jobs/xeno/turn_job.rb +5 -4
  19. data/app/mailers/xeno/application_mailer.rb +2 -0
  20. data/app/models/xeno/action.rb +7 -12
  21. data/app/models/xeno/application_record.rb +2 -0
  22. data/app/models/xeno/chat.rb +9 -11
  23. data/app/models/xeno/dedup.rb +7 -7
  24. data/app/models/xeno/event.rb +16 -20
  25. data/app/models/xeno/message.rb +2 -0
  26. data/app/models/xeno/pending_message.rb +4 -2
  27. data/app/models/xeno/session.rb +52 -67
  28. data/app/models/xeno/turn.rb +29 -34
  29. data/config/routes.rb +2 -0
  30. data/db/migrate/{20260806000001_move_transcript_support_tables_to_ruby_llm.rb → 20260808000001_create_ruby_llm_tables.rb} +25 -54
  31. data/db/migrate/{20260804000002_create_xeno_orchestration_tables.rb → 20260808000002_create_xeno_tables.rb} +44 -14
  32. data/docs/configuration.md +56 -0
  33. data/docs/runtime.md +4 -4
  34. data/exe/xeno +1 -1
  35. data/lib/generators/xeno/install/install_generator.rb +3 -2
  36. data/lib/generators/xeno/install/templates/agent.rb +1 -2
  37. data/lib/generators/xeno/install/templates/initializer.rb +6 -9
  38. data/lib/generators/xeno/tool/tool_generator.rb +4 -2
  39. data/lib/tasks/xeno_tasks.rake +2 -0
  40. data/lib/xeno/agent_config.rb +9 -8
  41. data/lib/xeno/agent_definition.rb +25 -28
  42. data/lib/xeno/approval_context.rb +2 -0
  43. data/lib/xeno/arguments.rb +8 -9
  44. data/lib/xeno/ask_question.rb +5 -5
  45. data/lib/xeno/channels/slack.rb +25 -29
  46. data/lib/xeno/channels.rb +10 -9
  47. data/lib/xeno/compaction.rb +20 -33
  48. data/lib/xeno/configuration.rb +35 -44
  49. data/lib/xeno/engine.rb +8 -7
  50. data/lib/xeno/errors.rb +11 -10
  51. data/lib/xeno/hooks.rb +7 -10
  52. data/lib/xeno/info.rb +3 -2
  53. data/lib/xeno/inputs.rb +26 -19
  54. data/lib/xeno/reaper.rb +13 -17
  55. data/lib/xeno/schedules.rb +8 -9
  56. data/lib/xeno/session_state.rb +8 -9
  57. data/lib/xeno/standalone/local_secret.rb +7 -6
  58. data/lib/xeno/standalone/model_refresh.rb +6 -6
  59. data/lib/xeno/standalone/puma.rb +9 -9
  60. data/lib/xeno/standalone.rb +22 -21
  61. data/lib/xeno/tool.rb +28 -25
  62. data/lib/xeno/turn_runner.rb +74 -96
  63. data/lib/xeno/version.rb +3 -1
  64. data/lib/xeno.rb +16 -14
  65. metadata +4 -9
  66. data/db/migrate/20260804000001_create_xeno_llm_tables.rb +0 -70
  67. data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +0 -8
  68. data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +0 -8
  69. data/db/migrate/20260805000003_create_xeno_dedups.rb +0 -14
  70. data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +0 -9
  71. data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +0 -8
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  # Gem-level runtime knobs, set from an initializer:
3
5
  #
@@ -6,77 +8,66 @@ module Xeno
6
8
  # end
7
9
  #
8
10
  class Configuration
9
- # Per-turn model-call budget (RubyLLM's own loop is unbounded; ours is not).
11
+ # Per-turn model-call budget: a turn must bound its model calls.
10
12
  attr_accessor :max_steps
11
13
 
12
- # A turn whose claim ledger reaches this many attempts is poison — it
13
- # fails instead of retrying forever.
14
+ # A turn whose claim ledger reaches this many attempts is poison — it fails instead of retrying
15
+ # forever.
14
16
  attr_accessor :max_turn_attempts
15
17
 
16
- # A running turn whose heartbeat is older than this is presumed dead and
17
- # reclaimable. Checkpoint replay makes takeover safe; a too-short window
18
- # risks overlap with a live-but-slow owner, a too-long one stalls the
19
- # session after a hard crash.
18
+ # A running turn whose heartbeat is older than this is presumed dead and reclaimable. Checkpoint
19
+ # replay makes takeover safe; a too-short window risks overlap with a live-but-slow owner, a
20
+ # too-long one stalls the session after a hard crash.
20
21
  attr_accessor :turn_stale_after
21
22
 
22
- # How often the background heartbeat beats while the runner is inside a
23
- # model call or a tool body (the calls that can outlast turn_stale_after).
24
- # nil (default) = a quarter of turn_stale_after, so a live owner always
25
- # beats several times per staleness window.
23
+ # How often the background heartbeat beats while the runner is inside a model call or a tool
24
+ # body (the calls that can outlast turn_stale_after). nil (default) = a quarter of
25
+ # turn_stale_after, so a live owner always beats several times per staleness window.
26
26
  attr_writer :heartbeat_interval
27
27
 
28
- # Per-session token budgets, enforced BEFORE each model call from the
29
- # persisted usage columns (the provider's own counts). Checked
30
- # independently per axis; the call that crosses is allowed to finish —
31
- # the NEXT call trips. Exceeded → deterministic turn failure with a
32
- # budget.exceeded event; the session recovers via reset. nil =
33
- # unlimited. Per-agent override: `limits input_tokens:, output_tokens:`
34
- # in agent.rb (false disables an axis).
28
+ # Per-session token budgets, enforced before each model call from the persisted usage counts,
29
+ # independently per axis. The call that crosses a cap finishes; the next call fails the turn
30
+ # with a budget.exceeded event, and the session recovers via reset. nil = unlimited. Per-agent
31
+ # override: `limits` in agent.rb.
35
32
  attr_accessor :max_input_tokens_per_session, :max_output_tokens_per_session
36
33
 
37
- # Compaction triggers when the last model call's context usage crosses
38
- # this fraction of the model's context window. nil/false
39
- # disables automatic compaction (manual stays available).
34
+ # Compaction triggers when the last model call's context usage crosses this fraction of the
35
+ # model's context window. nil/false disables automatic compaction (manual stays available).
40
36
  attr_accessor :compaction_threshold
41
37
 
42
- # Fallback context window (tokens) for models the registry doesn't know
43
- # (assume_model_exists). nil = automatic compaction never triggers for
44
- # unknown-window models.
38
+ # Fallback context window (tokens) for models the registry doesn't know (assume_model_exists).
39
+ # nil = automatic compaction never triggers for unknown-window models.
45
40
  attr_accessor :compaction_context_window
46
41
 
47
- # How many of the most recent user-message-anchored turns survive a
48
- # compaction verbatim (the "recent tail"). Everything earlier is
49
- # summarized and replaced.
42
+ # How many of the most recent user-message-anchored turns survive a compaction verbatim (the
43
+ # "recent tail"). Everything earlier is summarized and replaced.
50
44
  attr_accessor :compaction_tail_turns
51
45
 
52
- # Optional lambda answering "is the worker shutting down?" — probed by
53
- # the runner between steps; when true the turn releases its claim and
54
- # re-enqueues itself so a deploy-style stop resumes promptly on the next
55
- # worker instead of waiting out stale-heartbeat reclaim. nil = ask the
56
- # queue adapter (`queue_adapter.stopping?` — Solid Queue flips it on
57
- # worker shutdown; adapters that don't implement it just return false).
46
+ # Optional lambda answering "is the worker shutting down?", probed between steps. When true the
47
+ # turn releases its claim and re-enqueues itself, so a deploy-style stop resumes on the next
48
+ # worker instead of waiting out stale-heartbeat reclaim. nil = ask the queue adapter's
49
+ # `stopping?` (Solid Queue implements it; adapters that don't return false).
58
50
  attr_accessor :stopping_check
59
51
 
60
- # Fail-closed HTTP auth lambda. Receives the request; a falsy return is
61
- # a 401; a truthy return becomes the request principal.
52
+ # Fail-closed HTTP auth lambda. Receives the request; a falsy return is a 401; a truthy return
53
+ # becomes the request principal.
62
54
  attr_accessor :authenticate
63
55
 
64
56
  # How often the SSE stream polls for new events, in seconds.
65
57
  attr_accessor :stream_poll_interval
66
58
 
67
- # How many events one stream poll reads at most. Bounds the catch-up
68
- # read on long sessions (a reconnect from index 0 pages through history
69
- # in batches instead of loading every row in one query).
59
+ # How many events one stream poll reads at most. Bounds the catch-up read on long sessions (a
60
+ # reconnect from index 0 pages through history in batches instead of loading every row in one
61
+ # query).
70
62
  attr_accessor :stream_catch_up_batch
71
63
 
72
- # Optional hard cap on how long one SSE connection is served (seconds).
73
- # nil = until the session reaches a terminal status.
64
+ # Optional hard cap on how long one SSE connection is served (seconds). nil = until the session
65
+ # reaches a terminal status.
74
66
  attr_accessor :stream_max_duration
75
67
 
76
- # Optional lambda answering "is the server shutting down?" — when true,
77
- # open SSE streams close themselves so a graceful stop doesn't wait out
78
- # the in-flight-request window (Ctrl-C stays near-instant with tabs
79
- # open). nil = detect Puma's graceful stop automatically.
68
+ # Optional lambda answering "is the server shutting down?" — when true, open SSE streams close
69
+ # themselves so a graceful stop doesn't wait out the in-flight-request window (Ctrl-C stays
70
+ # near-instant with tabs open). nil = detect Puma's graceful stop automatically.
80
71
  attr_accessor :stream_shutdown_check
81
72
 
82
73
  def initialize
data/lib/xeno/engine.rb CHANGED
@@ -1,12 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  class Engine < ::Rails::Engine
3
5
  isolate_namespace Xeno
4
6
 
5
- # agent/ is a Zeitwerk root namespaced under Xeno with per-slot
6
- # submodules: agent/tools/get_weather.rb defines Xeno::Tools::GetWeather.
7
- # Non-constant files are xeno's to load: agent.rb is a config DSL,
8
- # schedules/ holds markdown, channels/ is a DSL slot in v0.1, lib/ is
9
- # plain shared Ruby (required manually by the author).
7
+ # agent/ is a Zeitwerk root namespaced under Xeno with per-slot submodules:
8
+ # agent/tools/get_weather.rb defines Xeno::Tools::GetWeather. Non-constant files are xeno's to
9
+ # load: agent.rb is a config DSL, schedules/ holds markdown, channels/ is a DSL slot, lib/ is
10
+ # plain shared Ruby required by the author.
10
11
  initializer "xeno.agent_autoload", before: :setup_main_autoloader do |app|
11
12
  agent_root = Xeno.agent_root
12
13
  if agent_root&.directory?
@@ -20,8 +21,8 @@ module Xeno
20
21
  end
21
22
  end
22
23
 
23
- # The cached definition holds tool classes; drop it whenever the app
24
- # reloads so dev picks up edits under agent/.
24
+ # The cached definition holds tool classes; drop it whenever the app reloads so dev picks up
25
+ # edits under agent/.
25
26
  config.to_prepare do
26
27
  Xeno.reset_definition!
27
28
  end
data/lib/xeno/errors.rb CHANGED
@@ -1,12 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  class Error < StandardError; end
3
5
 
4
- # Raised when a fenced write discovers its claim was reaped — the runner
5
- # is a zombie and must abort without touching anything else.
6
+ # Raised when a fenced write discovers its claim was reaped — the runner is a zombie and must
7
+ # abort without touching anything else.
6
8
  class Fenced < Error; end
7
9
 
8
- # Raised (internally) to park the turn: pending actions need human input.
9
- # Never RubyLLM's cancel! — its cleanup destroys in-flight rows.
10
+ # Raised (internally) to park the turn: pending actions need human input. Never RubyLLM's cancel!
11
+ # — its cleanup destroys in-flight rows.
10
12
  class Parked < Error
11
13
  attr_reader :actions
12
14
 
@@ -19,8 +21,8 @@ module Xeno
19
21
  # The per-turn runaway guard tripped.
20
22
  class MaxStepsExceeded < Error; end
21
23
 
22
- # A session token budget is spent: raised BEFORE the model call that
23
- # would overspend. Deterministic turn failure; reset recovers the session.
24
+ # A session token budget is spent: raised BEFORE the model call that would overspend.
25
+ # Deterministic turn failure; reset recovers the session.
24
26
  class BudgetExceeded < Error
25
27
  attr_reader :axis, :used, :limit
26
28
 
@@ -32,9 +34,8 @@ module Xeno
32
34
  end
33
35
  end
34
36
 
35
- # Raised (internally) between runner steps when the worker is shutting
36
- # down gracefully: the turn releases its claim and re-enqueues itself so
37
- # the next worker resumes it promptly (the AJ Continuation checkpoint
38
- # pattern). Not a failure — it never counts against the poison ladder.
37
+ # Raised between runner steps when the worker is shutting down gracefully: the turn releases its
38
+ # claim and re-enqueues itself so the next worker resumes it promptly. Not a failure — it never
39
+ # counts against the poison ladder.
39
40
  class Interrupted < Error; end
40
41
  end
data/lib/xeno/hooks.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  # Observe-only event handlers from agent/hooks/*.rb:
3
5
  #
@@ -7,16 +9,11 @@ module Xeno
7
9
  #
8
10
  # Xeno.hook "*" do |event| ... end # every event
9
11
  #
10
- # Semantics:
11
- # - Handlers fire AFTER the event row is durably committed (after_commit),
12
- # typed handlers first, then the wildcard. The block receives the
13
- # Xeno::Event record (event_type, data, index, session).
14
- # - Observe-only: return values are ignored; hooks cannot veto anything
15
- # or inject model context. A raising handler is logged and skipped — a
16
- # hook can never break the runtime or fail the turn.
17
- # - AT-LEAST-ONCE: replayed steps re-emit new events with new indexes.
18
- # Key once-per-step side effects on (turn_id, step) from the data; key
19
- # stored content on (session_id, index).
12
+ # Handlers fire after the event row commits, typed handlers before the wildcard, with the
13
+ # Xeno::Event record as the argument. Return values are ignored and a raising handler is logged
14
+ # and skipped — a hook can never veto, inject context, or fail the turn. Delivery is
15
+ # at-least-once: replayed steps re-emit events, so key side effects on (turn_id, step) and stored
16
+ # content on (session_id, index).
20
17
  module Hooks
21
18
  module_function
22
19
 
data/lib/xeno/info.rb CHANGED
@@ -1,6 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # Renders the resolved agent for `rake xeno:info` — the discovery
3
- # diagnostics surface.
4
+ # Renders the resolved agent for `rake xeno:info` — the discovery diagnostics surface.
4
5
  module Info
5
6
  module_function
6
7
 
data/lib/xeno/inputs.rb CHANGED
@@ -1,15 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # Resolving parked work: approvals, denials, and question answers. Each
3
- # resolution updates the action and — once no other action on the turn is
4
- # still awaiting input — flips the turn back to pending and enqueues the
5
- # resume job. A parked session holds zero compute; this is the only way
6
- # back in.
7
- module Inputs
4
+ # Resolves parked work: approvals, denials, and question answers. Each resolution updates its
5
+ # action; when nothing on the turn still awaits input, the turn flips back to pending and the
6
+ # resume job enqueues. A parked session holds no process — this is the only way back in.
7
+ module Inputs # :nodoc:
8
8
  module_function
9
9
 
10
10
  def approve!(action, principal: nil)
11
11
  resolve(action) do
12
12
  action.update!(status: "approved", resolved_at: Time.current, resolved_by: principal)
13
+ mirror_decision(action, "approved")
13
14
  end
14
15
  end
15
16
 
@@ -22,6 +23,7 @@ module Xeno
22
23
  resolved_at: Time.current,
23
24
  resolved_by: principal
24
25
  )
26
+ mirror_decision(action, "denied")
25
27
  end
26
28
  end
27
29
 
@@ -38,14 +40,20 @@ module Xeno
38
40
  end
39
41
  end
40
42
 
41
- # Shared plumbing: guard the state, apply the resolution, resume when
42
- # the turn has nothing else to wait for. Resolution, status flip, and
43
- # the resume enqueue commit in ONE transaction: on a DB-backed queue
44
- # (Solid Queue shares the app database, and ActiveJob 8.1 enqueues
45
- # in-transaction by default) a crash can never separate "approved" from
46
- # "job exists" — either everything landed or the input is still pending
47
- # and the human just retries. Backends that defer or lose the enqueue
48
- # fall back to the reaper's sweeps.
43
+ # The action row stays authoritative (kind, payload, who, when); the decision is mirrored onto
44
+ # the standard tool-call column so any RubyLLM-aware tooling reads it natively. Questions have
45
+ # no place there and are not mirrored.
46
+ def mirror_decision(action, decision)
47
+ return unless action.kind == "tool"
48
+
49
+ record = RubyLLM::ActiveRecord::ToolCall.find_by(tool_call_id: action.tool_call_id)
50
+ record&.update!(approval: decision)
51
+ end
52
+
53
+ # Guards the state, applies the resolution, and resumes when the turn has nothing left to wait
54
+ # for. Resolution, status flip, and resume enqueue commit in one transaction, so on a DB-backed
55
+ # queue a crash cannot separate "approved" from "job exists". Backends that defer or lose the
56
+ # enqueue fall back to the reaper.
49
57
  def resolve(action)
50
58
  unless action.status == "pending_approval"
51
59
  raise Xeno::Error, "action #{action.id} is not awaiting input (status: #{action.status})"
@@ -58,14 +66,13 @@ module Xeno
58
66
  action
59
67
  end
60
68
 
61
- # Resuming with unanswered inputs would generate against partial tool
62
- # results (an invalid provider state) — the runner would just re-park,
63
- # so don't bother waking it until everything is resolved.
69
+ # Resuming with unanswered inputs would generate against partial tool results — an invalid
70
+ # provider state the runner would re-park on. Wake it only when everything is resolved.
64
71
  def resume_turn(turn)
65
72
  return if turn.actions.where(status: "pending_approval").exists?
66
73
 
67
- # Resumes are human-driven and unbounded — counted apart from the
68
- # failure `attempts` so approvals can never poison the turn (H2).
74
+ # Resumes are human-driven and unbounded — counted apart from the failure `attempts` so
75
+ # approvals can never poison the turn.
69
76
  resumed = Turn.where(id: turn.id, status: "waiting")
70
77
  .update_all("status = 'pending', resumes = resumes + 1")
71
78
  return unless resumed == 1
data/lib/xeno/reaper.rb CHANGED
@@ -1,26 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # The second half of the failure matrix: nothing else owns turns that
3
- # fall out of the retry ladder. The reaper finds work that SHOULD be
4
- # running but has no live owner and re-enqueues it. Duplicate-safe by
5
- # construction — the claim CAS means a redundant TurnJob exits quietly,
6
- # and a poisoned turn fails properly at claim time.
4
+ # Finds turns that should be running but have no live owner and re-enqueues them. Duplicate-safe:
5
+ # the claim CAS makes a redundant TurnJob exit quietly, and a poisoned turn fails at claim time.
7
6
  #
8
- # Wiring: ReaperJob rides Solid Queue's recurring machinery (a managed
9
- # entry written by Schedules.sync! in mounted mode, storage/recurring.yml
10
- # in standalone mode). Other queue backends trigger it however they run
11
- # periodic work: `Xeno::ReaperJob.perform_later` or `bin/rails xeno:reap`.
12
- module Reaper
7
+ # ReaperJob rides Solid Queue's recurring machinery. Other queue backends trigger it however they
8
+ # run periodic work: Xeno::ReaperJob.perform_later or `bin/rails xeno:reap`.
9
+ module Reaper # :nodoc:
13
10
  module_function
14
11
 
15
12
  # One sweep. Rescues, in order:
16
- # 1. running turns with a stale heartbeat — the owner died mid-step
17
- # (kill -9) or the queue exhausted its retries with the job in the
18
- # failed set; claim! reclaims these directly.
19
- # 2. stale pending turns — staged or released-for-retry, but no job
20
- # ever came back (lost/discarded job).
13
+ # 1. running turns with a stale heartbeat — the owner died or the
14
+ # queue exhausted its retries; claim! reclaims these directly.
15
+ # 2. stale pending turns — staged or released for retry, but the job
16
+ # never came back.
21
17
  # 3. waiting turns with nothing left to wait for — every action
22
- # resolved but the resume enqueue never happened (crash between
23
- # resolve and enqueue). Genuinely parked turns are never touched.
18
+ # resolved but the resume enqueue never happened.
19
+ # Genuinely parked turns are never touched.
24
20
  def sweep!(stale_before: Xeno.config.turn_stale_after.ago)
25
21
  rescued = []
26
22
 
@@ -1,17 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "yaml"
2
4
 
3
5
  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
+ # Compiles agent/schedules/*.md into Solid Queue recurring entries. There is no polling
6
7
  # dispatcher: the queue's own recurring machinery fires ScheduleJob.
7
8
  #
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
9
+ # Only keys under the managed prefix are touched, so hand-written entries survive a sync. User
10
+ # schedules are written for every environment except development, where the dispatch endpoint
11
+ # triggers them by name. The reaper entry is runtime infrastructure and goes into every
12
+ # environment.
13
+ module Schedules # :nodoc:
15
14
  MANAGED_PREFIX = "xeno_".freeze
16
15
  REAPER_KEY = "#{MANAGED_PREFIX}runtime_reaper".freeze
17
16
 
@@ -1,11 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  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.
4
+ # The per-session KV store: working state for one conversation, not long-term memory. Values
5
+ # round-trip through JSON (symbols become strings; unserializable values are rejected up front).
6
+ # Reads always hit the database, and writes take the session row lock so read-modify-write merges
7
+ # never lose entries. Cleared by `reset`.
9
8
  class SessionState
10
9
  def initialize(session)
11
10
  @session = session
@@ -20,8 +19,8 @@ module Xeno
20
19
  current
21
20
  end
22
21
 
23
- # Merges the entries in one locked write. Keys stringify; values must
24
- # be JSON-serializable (Xeno::Error otherwise).
22
+ # Merges the entries in one locked write. Keys stringify; values must be JSON-serializable
23
+ # (Xeno::Error otherwise).
25
24
  def update(entries)
26
25
  normalized = normalize(entries)
27
26
  @session.with_lock do
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "securerandom"
2
4
  require "fileutils"
3
5
 
@@ -5,13 +7,12 @@ module Xeno
5
7
  module Standalone
6
8
  module_function
7
9
 
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.
10
+ # A persisted local secret so standalone sessions survive restarts without anyone managing
11
+ # SECRET_KEY_BASE. Owner-only (0600) — it signs cookies/sessions; looser pre-existing files are
12
+ # tightened on read.
12
13
  #
13
- # Lives apart from xeno/standalone so tests can exercise it without
14
- # defining the Rails::Application (which self-registers on inheritance).
14
+ # Lives apart from xeno/standalone so tests can exercise it without defining the
15
+ # Rails::Application (which self-registers on inheritance).
15
16
  def local_secret(root)
16
17
  path = root.join("storage", ".local_secret")
17
18
  FileUtils.mkdir_p(path.dirname)
@@ -1,13 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  module Standalone
3
5
  module_function
4
6
 
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).
7
+ # First boot only (the registry table is empty): pulls the live model catalog so current model
8
+ # ids resolve without a manual refresh. The definition loads first because agent.rb's
9
+ # RubyLLM.configure sets the provider keys. Never fatal, never repeated once the table has rows;
10
+ # XENO_SKIP_MODEL_REFRESH=1 skips it.
11
11
  def refresh_models!
12
12
  return if ENV["XENO_SKIP_MODEL_REFRESH"]
13
13
 
@@ -1,17 +1,17 @@
1
- # Gem-owned puma config for standalone mode (`xeno server`): the whole
2
- # stack — web + Solid Queue supervisor — inside one Puma process.
1
+ # frozen_string_literal: true
2
+
3
+ # Gem-owned puma config for standalone mode (`xeno server`): the whole stack — web + Solid Queue
4
+ # supervisor — inside one Puma process.
3
5
  threads_count = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
4
6
  threads threads_count, threads_count
5
7
 
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.
8
+ # Loopback by default: development runs with the dev auth bypass, and an unauthenticated dev UI must
9
+ # not be exposed to the LAN. Opt out with HOST=0.0.0.0 for containers/proxies that need it.
9
10
  bind "tcp://#{ENV.fetch("HOST", "127.0.0.1")}:#{ENV.fetch("PORT", 3000)}"
10
11
 
11
12
  plugin :solid_queue if ENV.fetch("SOLID_QUEUE_IN_PUMA", "1") != "0"
12
13
 
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.
14
+ # Ctrl-C must win. Live SSE streams are in-flight requests and Puma waits for in-flight requests
15
+ # forever by default — an open browser tab made shutdown hang. Hard-stop leftover streams after 5s;
16
+ # state is rows, nothing is lost.
17
17
  force_shutdown_after ENV.fetch("XENO_FORCE_SHUTDOWN_AFTER", 5).to_i
@@ -1,13 +1,18 @@
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.
1
+ # frozen_string_literal: true
2
+
3
+ # = Standalone mode
4
+ #
5
+ # An agent shouldn't need a whole Rails app of its own. `xeno new my-agent` scaffolds a directory
6
+ # with a handful of visible files — agent/, a Gemfile, config.ru, .gitignore — and the entire
7
+ # Rails::Application lives here, inside the gem:
4
8
  #
5
9
  # # config.ru
6
10
  # require "xeno/standalone"
7
11
  # run Xeno.rails_app
8
12
  #
9
- # SQLite + Solid Queue inside Puma (`xeno server`) make it one process with
10
- # zero external services; migrations run at boot.
13
+ # `xeno server` boots it as one process with zero external services: SQLite for storage, Solid
14
+ # Queue inside Puma for jobs, migrations applied at boot. When the agent outgrows this, mount the
15
+ # engine in a full Rails app and bring the agent/ directory along unchanged.
11
16
  require "rails"
12
17
  require "active_model/railtie"
13
18
  require "active_record/railtie"
@@ -15,8 +20,8 @@ require "active_job/railtie"
15
20
  require "action_controller/railtie"
16
21
  require "action_view/railtie"
17
22
 
18
- # A config.ru-only app has no Bundler.require(*Rails.groups); pull in what
19
- # the scaffolded Gemfile provides.
23
+ # A config.ru-only app has no Bundler.require(*Rails.groups); pull in what the scaffolded Gemfile
24
+ # provides.
20
25
  require "solid_queue"
21
26
  require "sqlite3"
22
27
 
@@ -51,9 +56,8 @@ module Xeno
51
56
  "adapter" => "sqlite3",
52
57
  "database" => ENV.fetch("XENO_DATABASE", "storage/xeno_#{Rails.env}.sqlite3"),
53
58
  "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
59
+ # Web and Solid Queue's forked processes share this one file; without a busy timeout the
60
+ # sqlite3 adapter does not wait for locks and every collision raises
57
61
  # SQLite3::BusyException. Same 5s default rails new generates.
58
62
  "timeout" => ENV.fetch("XENO_DATABASE_TIMEOUT", 5000).to_i
59
63
  }
@@ -64,12 +68,11 @@ module Xeno
64
68
  config.secret_key_base = ENV["SECRET_KEY_BASE"] || Xeno::Standalone.local_secret(APP_ROOT)
65
69
  end
66
70
 
67
- module Standalone
71
+ module Standalone # :nodoc:
68
72
  module_function
69
73
 
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).
74
+ # Boot: initialize the app, then bring the database up to date — the engine's migrations plus
75
+ # Solid Queue's tables (loaded straight from the solid_queue gem's install template).
73
76
  def boot!
74
77
  ENV["RAILS_ENV"] ||= "development"
75
78
  FileUtils.mkdir_p(StandaloneApplication::APP_ROOT.join("storage"))
@@ -89,11 +92,9 @@ module Xeno
89
92
  Rails.application
90
93
  end
91
94
 
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.
95
+ # Solid Queue's recurring machinery fires the reaper (always) and the compiled schedules
96
+ # (outside development) in standalone mode. The file lives under storage/ so the scaffold stays
97
+ # four visible files; the env var points Solid Queue's supervisor at it.
97
98
  def write_recurring_config!
98
99
  section = { Xeno::Schedules::REAPER_KEY => Xeno::Schedules.reaper_entry }
99
100
  unless Rails.env.development?
@@ -123,8 +124,8 @@ module Xeno
123
124
  File.join(spec.gem_dir, "lib", "generators", "solid_queue", "install", "templates", "db", "queue_schema.rb")
124
125
  end
125
126
 
126
- # The gem-owned puma config used by `xeno server` — Solid Queue runs
127
- # inside Puma (SOLID_QUEUE_IN_PUMA), one process total.
127
+ # The gem-owned puma config used by `xeno server` — Solid Queue runs inside Puma
128
+ # (SOLID_QUEUE_IN_PUMA), one process total.
128
129
  def puma_config_path
129
130
  File.expand_path("standalone/puma.rb", __dir__)
130
131
  end