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
@@ -0,0 +1,56 @@
1
+ # Configuration reference
2
+
3
+ Every knob on `Xeno.configure`. Standalone apps configure in `agent/agent.rb`;
4
+ mounted apps use `config/initializers/xeno.rb`. The mechanics behind these knobs
5
+ — claims, heartbeats, budget and compaction semantics — are documented in
6
+ [runtime.md](runtime.md).
7
+
8
+ ```ruby
9
+ Xeno.configure do |config|
10
+ config.max_steps = 20
11
+ config.turn_stale_after = 5.minutes
12
+ end
13
+ ```
14
+
15
+ ## The turn loop
16
+
17
+ | Setting | Default | Meaning |
18
+ |---|---|---|
19
+ | `max_steps` | `20` | Per-turn model-call budget. |
20
+ | `max_turn_attempts` | `5` | Attempts before a turn is declared poison and fails. |
21
+ | `turn_stale_after` | `5.minutes` | A running turn with an older heartbeat is presumed dead and reclaimable. |
22
+ | `heartbeat_interval` | a quarter of `turn_stale_after` | Heartbeat cadence during model calls and tool bodies. |
23
+
24
+ ## Budgets
25
+
26
+ | Setting | Default | Meaning |
27
+ |---|---|---|
28
+ | `max_input_tokens_per_session` | `nil` (unlimited) | Per-session input-token budget. |
29
+ | `max_output_tokens_per_session` | `nil` (unlimited) | Per-session output-token budget. |
30
+
31
+ Per-agent override in `agent.rb`: `limits input_tokens:, output_tokens:`
32
+ (`false` disables an axis).
33
+
34
+ ## Compaction
35
+
36
+ | Setting | Default | Meaning |
37
+ |---|---|---|
38
+ | `compaction_threshold` | `0.9` | Fraction of the context window that triggers automatic compaction. `nil`/`false` disables it; manual stays available. |
39
+ | `compaction_context_window` | `nil` | Fallback window (tokens) for models the registry doesn't know. `nil`: automatic compaction never triggers for them. |
40
+ | `compaction_tail_turns` | `2` | Recent turns kept verbatim through a compaction. |
41
+
42
+ ## HTTP and streaming
43
+
44
+ | Setting | Default | Meaning |
45
+ |---|---|---|
46
+ | `authenticate` | `nil` | Fail-closed auth lambda: falsy return is a 401, truthy becomes the request principal. |
47
+ | `stream_poll_interval` | `0.25` | Seconds between event-stream polls. |
48
+ | `stream_catch_up_batch` | `500` | Max events per poll; bounds catch-up reads on long sessions. |
49
+ | `stream_max_duration` | `nil` | Hard cap (seconds) on one stream connection. `nil`: until the session ends. |
50
+ | `stream_shutdown_check` | `nil` | Lambda: "is the server shutting down?" — open streams close when true. `nil` detects Puma's graceful stop. |
51
+
52
+ ## Deploys
53
+
54
+ | Setting | Default | Meaning |
55
+ |---|---|---|
56
+ | `stopping_check` | `nil` | Lambda: "is the worker shutting down?" — probed between steps; a true return releases the claim and re-enqueues. `nil` asks the queue adapter. |
data/docs/runtime.md CHANGED
@@ -9,9 +9,9 @@ how the durability machinery actually works. The README sells it; this explains
9
9
  `running | waiting | completed | failed`. Owns one transcript chat, the turn ledger,
10
10
  the event stream, and at most one `continuation_token` (channel-owned resume handle,
11
11
  unique among ACTIVE sessions). A session stays active indefinitely — turns finishing
12
- never retire it; **`reset` is the only terminal transition in v0.1**, and it nulls
12
+ never retire it; **`reset` is the only terminal transition**, and it nulls
13
13
  the token (audit copy in `metadata`) so the same handle can start fresh. The
14
- `failed` status and `session.failed` event are reserved (never produced in v0.1).
14
+ `failed` status and `session.failed` event are reserved (never produced today).
15
15
  - **Turn** (`xeno_turns`) — one user message (or several, when queued messages fold)
16
16
  and all work until the agent responds. One ActiveJob. Statuses:
17
17
  `pending | running | waiting | completed | failed | cancelled`.
@@ -37,8 +37,8 @@ claim! (CAS) → clean crash artifacts → loop:
37
37
 
38
38
  Rules inherited from the RubyLLM v2 investigation (each empirically verified):
39
39
 
40
- 1. Never `complete` (unbounded) or `run_tools` (re-executes everything on retry). The
41
- runner IS the loop.
40
+ 1. Never `complete` (a turn must bound its model calls) or `run_tools` (no approval
41
+ gate, no per-call action checkpoint). The runner IS the loop.
42
42
  2. A killed process leaves a blank assistant row (`before_message` persists it before
43
43
  the HTTP call) that makes `complete?` read true — trailing blank assistant rows are
44
44
  deleted on entry.
data/exe/xeno CHANGED
@@ -28,7 +28,7 @@ when "new"
28
28
  gem "xeno"
29
29
  # RubyLLM v2 is unreleased and the published 1.16.0 gem is incompatible;
30
30
  # xeno is built against this exact commit. Drop the pin at RubyLLM v2 GA.
31
- gem "ruby_llm", github: "crmne/ruby_llm", ref: "2aaddf96"
31
+ gem "ruby_llm", github: "crmne/ruby_llm", ref: "be80b6f1"
32
32
  # Loads .env at boot. Remove if you manage secrets another way.
33
33
  gem "dotenv"
34
34
  gem "puma"
@@ -1,7 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  module Generators
3
- # Mounted-mode install: initializer, engine mount, migrations, and the
4
- # agent/ scaffold.
5
+ # Mounted-mode install: initializer, engine mount, migrations, and the agent/ scaffold.
5
6
  class InstallGenerator < Rails::Generators::Base
6
7
  source_root File.expand_path("templates", __dir__)
7
8
 
@@ -1,4 +1,3 @@
1
1
  Xeno.agent do
2
- # Any RubyLLM model id; provider options pass through.
3
- # model "claude-sonnet-4-6"
2
+ # Any RubyLLM model id; provider options pass through. model "claude-sonnet-4-6"
4
3
  end
@@ -1,8 +1,7 @@
1
1
  Xeno.configure do |config|
2
- # HTTP auth is FAIL-CLOSED: every endpoint (except health) returns 401
3
- # until you set this. The lambda receives the request; return something
4
- # falsy to reject, or a truthy value to accept — whatever you return
5
- # becomes the session's principal (visible to tools and approval lambdas).
2
+ # HTTP auth is FAIL-CLOSED: every endpoint (except health) returns 401 until you set this. The
3
+ # lambda receives the request; return something falsy to reject, or a truthy value to accept —
4
+ # whatever you return becomes the session's principal (visible to tools and approval lambdas).
6
5
  #
7
6
  # config.authenticate = ->(request) do
8
7
  # token = request.headers["Authorization"]&.delete_prefix("Bearer ")
@@ -11,10 +10,8 @@ Xeno.configure do |config|
11
10
  # )
12
11
  # end
13
12
 
14
- # Per-turn model-call budget (runaway protection).
15
- # config.max_steps = 20
13
+ # Per-turn model-call budget (runaway protection). config.max_steps = 20
16
14
 
17
- # How long a silent turn is presumed alive before another worker may
18
- # reclaim it. Keep it above your slowest model call.
19
- # config.turn_stale_after = 5.minutes
15
+ # How long a silent turn is presumed alive before another worker may reclaim it. Keep it above
16
+ # your slowest model call. config.turn_stale_after = 5.minutes
20
17
  end
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  module Generators
3
- # rails g xeno:tool GetWeather → agent/tools/get_weather.rb
4
- # (the path supplies the runtime name: get_weather)
5
+ # rails g xeno:tool GetWeather → agent/tools/get_weather.rb (the path supplies the runtime name:
6
+ # get_weather)
5
7
  class ToolGenerator < Rails::Generators::NamedBase
6
8
  source_root File.expand_path("templates", __dir__)
7
9
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  namespace :xeno do
2
4
  desc "Print the resolved agent: config, instructions, tools, diagnostics"
3
5
  task info: :environment do
@@ -1,6 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # The config DSL evaluated from agent/agent.rb. Optional — defaults apply
3
- # when the file is absent.
4
+ # The config DSL evaluated from agent/agent.rb. Optional — defaults apply when the file is absent.
4
5
  #
5
6
  # Xeno.agent do
6
7
  # model "anthropic/claude-sonnet-5"
@@ -25,8 +26,8 @@ module Xeno
25
26
  # limits input_tokens: 2_000_000, output_tokens: 200_000
26
27
  # end
27
28
  #
28
- # `false` disables an axis even when a global cap is set; an axis left
29
- # out inherits the global `Xeno.config.max_*_tokens_per_session`.
29
+ # `false` disables an axis even when a global cap is set; an axis left out inherits the global
30
+ # `Xeno.config.max_*_tokens_per_session`.
30
31
  def limits(**caps)
31
32
  unless caps.empty?
32
33
  unknown = caps.keys - %i[input_tokens output_tokens]
@@ -37,16 +38,16 @@ module Xeno
37
38
  @limits
38
39
  end
39
40
 
40
- # The effective cap for one axis: agent override first (false = off),
41
- # then the global config. nil = unlimited.
41
+ # The effective cap for one axis: agent override first (false = off), then the global config.
42
+ # nil = unlimited.
42
43
  def token_limit(axis, global)
43
44
  return nil if @limits[axis] == false
44
45
 
45
46
  @limits.fetch(axis, nil) || global
46
47
  end
47
48
 
48
- # DSL setter and reader in one: `model "id", **options` inside the block,
49
- # `config.model` afterwards.
49
+ # DSL setter and reader in one: `model "id", **options` inside the block, `config.model`
50
+ # afterwards.
50
51
  def model(id = nil, **options)
51
52
  if id
52
53
  @model_id = id
@@ -1,8 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # The resolved agent: everything discovered under the app's agent/
3
- # directory, plus diagnostics for anything misplaced or broken.
4
- # Discovery never raises — a broken agent directory yields a definition
5
- # whose diagnostics say exactly what is wrong (surfaced by `rake xeno:info`).
4
+ # The resolved agent: everything discovered under the app's agent/ directory, plus diagnostics for
5
+ # anything misplaced or broken. Discovery never raises — a broken agent directory yields a
6
+ # definition whose diagnostics say exactly what is wrong (surfaced by `rake xeno:info`).
6
7
  class AgentDefinition
7
8
  SLOT_DIRS = %w[tools skills channels schedules hooks lib].freeze
8
9
  TOOL_SLUG = /\A[a-z][a-z0-9_]*\z/
@@ -19,9 +20,9 @@ module Xeno
19
20
  attr_reader :root, :name, :config, :instructions, :dynamic_instructions,
20
21
  :tools, :schedules, :channels, :hooks, :diagnostics
21
22
 
22
- # Builds the definition for the app's agent root. `resolver` maps a tool
23
- # file's camelized basename to its constant; the default asks Zeitwerk
24
- # (via const_get on Xeno::Tools) and is only overridden in tests.
23
+ # Builds the definition for the app's agent root. `resolver` maps a tool file's camelized
24
+ # basename to its constant; the default asks Zeitwerk (via const_get on Xeno::Tools) and is only
25
+ # overridden in tests.
25
26
  def self.load(root, name:, resolver: nil)
26
27
  new(root: root, name: name, resolver: resolver).tap(&:discover)
27
28
  end
@@ -62,11 +63,9 @@ module Xeno
62
63
 
63
64
  def dynamic_instructions? = !@dynamic_instructions.nil?
64
65
 
65
- # The system prompt for one turn: static markdown first, the dynamic
66
- # block's return appended (resolved fresh at every turn stage with the
67
- # session context — the current_user use case). A raising block never
68
- # bricks the session: the turn proceeds on the static instructions and
69
- # the failure is logged.
66
+ # The system prompt for one turn: static markdown first, then the dynamic block's return,
67
+ # resolved fresh at every turn stage with the session context. A raising block never bricks the
68
+ # session — the turn proceeds on the static instructions and the failure is logged.
70
69
  def instructions_for(session: nil)
71
70
  parts = [ instructions ]
72
71
  if @dynamic_instructions
@@ -116,9 +115,8 @@ module Xeno
116
115
  end
117
116
  end
118
117
 
119
- # agent/instructions.rb — `Xeno.instructions do |context| ... end`,
120
- # resolved at turn-stage time (context: session, principal) and appended
121
- # to the static markdown.
118
+ # agent/instructions.rb — `Xeno.instructions do |context| ... end`, resolved at turn-stage time
119
+ # (context: session, principal) and appended to the static markdown.
122
120
  def load_dynamic_instructions
123
121
  file = root.join("instructions.rb")
124
122
  return unless file.file?
@@ -153,10 +151,9 @@ module Xeno
153
151
  next
154
152
  end
155
153
 
156
- # Xeno::Tool required, not just RubyLLM::Tool: a plain RubyLLM
157
- # subclass leaks a namespaced wire name (`xeno--tools--foo`) that
158
- # misses the slug-keyed lookup, and it has no approval API — the
159
- # gate would silently fail open.
154
+ # Xeno::Tool required, not just RubyLLM::Tool: a plain RubyLLM subclass leaks a namespaced
155
+ # wire name (`xeno--tools--foo`) that misses the slug-keyed lookup, and it has no approval
156
+ # API — the gate would silently fail open.
160
157
  unless klass.is_a?(Class) && klass < Xeno::Tool
161
158
  error "tools/#{file.basename}: Xeno::Tools::#{slug.camelize} must subclass Xeno::Tool " \
162
159
  "(naming and the approval gate depend on it)"
@@ -171,9 +168,9 @@ module Xeno
171
168
  Xeno::Tools.const_get(slug.camelize)
172
169
  end
173
170
 
174
- # agent/schedules/*.md — YAML frontmatter with a cron: line, body is the
175
- # task prompt. Compiled to Solid Queue recurring entries by
176
- # `rake xeno:schedules:sync`; never fired on cadence in development.
171
+ # agent/schedules/*.md — YAML frontmatter with a cron: line, body is the task prompt. Compiled
172
+ # to Solid Queue recurring entries by `rake xeno:schedules:sync`; never fired on cadence in
173
+ # development.
177
174
  def discover_schedules
178
175
  dir = root.join("schedules")
179
176
  return unless dir.directory?
@@ -202,9 +199,9 @@ module Xeno
202
199
  end
203
200
  end
204
201
 
205
- # agent/channels/*.rb — DSL files (`Xeno.channel :slack do ... end`).
206
- # Loading registers the channel globally; the definition records what
207
- # each file declared for diagnostics and xeno:info.
202
+ # agent/channels/*.rb — DSL files (`Xeno.channel :slack do ... end`). Loading registers the
203
+ # channel globally; the definition records what each file declared for diagnostics and
204
+ # xeno:info.
208
205
  def discover_channels
209
206
  dir = root.join("channels")
210
207
  return unless dir.directory?
@@ -228,9 +225,9 @@ module Xeno
228
225
  end
229
226
  end
230
227
 
231
- # agent/hooks/*.rb — observe-only handlers (`Xeno.hook "type" do … end`).
232
- # One file may declare several; they merge across files. Handlers for
233
- # unknown event types get a warning (typos never fire).
228
+ # agent/hooks/*.rb — observe-only handlers (`Xeno.hook "type" do … end`). One file may declare
229
+ # several; they merge across files. Handlers for unknown event types get a warning (typos never
230
+ # fire).
234
231
  def discover_hooks
235
232
  dir = root.join("hooks")
236
233
  return unless dir.directory?
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  # What an approval lambda gets to look at.
3
5
  ApprovalContext = Struct.new(:session, :turn, :tool_name, :arguments, :principal, keyword_init: true)
@@ -1,15 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # Models send JSON-typed arguments loosely — the first field run had
3
- # llama sending "4200" (String) for an integer-typed parameter, which
4
- # flowed into execute unchecked. Casts each argument per the tool's
5
- # declared parameter type; anything uncoercible becomes a validation
6
- # error the runner turns into an error tool result (the model retries
7
- # with fixed arguments) — never an exception into execute.
8
- module Arguments
4
+ # Casts each tool argument to its declared parameter type. Models send JSON-typed arguments
5
+ # loosely ("4200" for an integer parameter); an uncoercible value becomes a validation error the
6
+ # runner turns into an error tool result for the model to retry — never an exception into execute.
7
+ module Arguments # :nodoc:
9
8
  module_function
10
9
 
11
- # Returns [coerced_arguments, errors]. Undeclared keys pass through
12
- # untouched (schema-based tools declare nothing here).
10
+ # Returns [coerced_arguments, errors]. Undeclared keys pass through untouched (schema-based
11
+ # tools declare nothing here).
13
12
  def coerce(tool_class, arguments)
14
13
  declared = tool_class.respond_to?(:declared_parameters) ? tool_class.declared_parameters : {}
15
14
  return [ arguments, [] ] if declared.empty? || !arguments.is_a?(Hash)
@@ -1,9 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # The one framework tool in v0.1. It has no executable body: the model
3
- # calling it parks the turn with an input.requested event, and a human's
4
- # answer is injected as the tool result on resume — the same mechanics as
5
- # approvals. Registered on every chat (outside the Xeno::Tools namespace,
6
- # which belongs to the app's agent/tools/).
4
+ # The built-in question tool. It has no executable body: the model calling it parks the turn, and
5
+ # a human's answer is injected as the tool result on resume — the same mechanics as approvals.
6
+ # Registered on every chat, outside the Xeno::Tools namespace that belongs to the app.
7
7
  class AskQuestion < Tool
8
8
  description "Ask the human a question and wait for their answer. " \
9
9
  "Use when you need information or a decision only they can provide."
@@ -1,15 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "openssl"
2
4
  require "net/http"
3
5
 
4
6
  module Xeno
5
7
  module Channels
6
- # The Slack channel: Events API webhook with constant-time signature
7
- # verification, thread-scoped sessions (continuation token =
8
- # "slack:<channel>:<thread_ts>"), replies posted at turn completion,
9
- # and plain-text approvals ("approve"/"deny"/an answer) in the thread.
10
- #
11
- # v0.1 scope: mentions and DMs start sessions; thread replies continue
12
- # them. Post-then-edit streaming and Block Kit buttons are v0.2.
8
+ # The Slack channel: Events API webhook with constant-time signature verification, thread-scoped
9
+ # sessions (continuation token = "slack:<channel>:<thread_ts>"), and plain-text approvals in the
10
+ # thread. Mentions and DMs start sessions; thread replies continue them. Post-then-edit
11
+ # streaming is opt-in (stream_replies); Block Kit buttons are not implemented.
13
12
  class Slack
14
13
  SIGNATURE_VERSION = "v0".freeze
15
14
  TIMESTAMP_TOLERANCE = 300 # seconds; replayed webhooks are rejected
@@ -40,9 +39,9 @@ module Xeno
40
39
  @api_base
41
40
  end
42
41
 
43
- # Opt-in post-then-edit streaming: the reply posts on the first model
44
- # delta and is edited (~1s cadence, rate-limit aware) until the final
45
- # text lands. Off by default — replies post once at completion.
42
+ # Opt-in post-then-edit streaming: the reply posts on the first model delta and is edited (~1s
43
+ # cadence, rate-limit aware) until the final text lands. Off by default — replies post once at
44
+ # completion.
46
45
  def stream_replies(value = nil)
47
46
  @stream_replies = value unless value.nil?
48
47
  @stream_replies
@@ -82,8 +81,8 @@ module Xeno
82
81
  post_message(channel: channel_id, thread_ts: thread_ts, text: content.to_s)
83
82
  end
84
83
 
85
- # One per content-bearing model reply when stream_replies is on: the
86
- # runner pushes deltas, we post-then-edit in the thread.
84
+ # One per content-bearing model reply when stream_replies is on: the runner pushes deltas, we
85
+ # post-then-edit in the thread.
87
86
  def streamer_for(session)
88
87
  return nil unless stream_replies
89
88
 
@@ -119,12 +118,10 @@ module Xeno
119
118
  end
120
119
  end
121
120
 
122
- # Post-then-edit delivery for ONE streaming model reply. The first
123
- # content delta posts the thread message; later deltas edit it at most
124
- # once per EDIT_INTERVAL; finish writes the durable final text. Rate
125
- # limits defer the next edit (Retry-After); any hard failure turns
126
- # streaming off for this reply — the completion post is the fallback,
127
- # and the durable truth is rows either way.
121
+ # Post-then-edit delivery for one streaming reply: the first content delta posts the thread
122
+ # message, later deltas edit it at most once per EDIT_INTERVAL, finish writes the final text.
123
+ # Rate limits defer the next edit; a hard failure turns streaming off for this reply and the
124
+ # completion post is the fallback.
128
125
  class Streamer
129
126
  EDIT_INTERVAL = 1.0 # seconds — comfortably under chat.update's tier
130
127
 
@@ -147,8 +144,8 @@ module Xeno
147
144
  @ts.nil? ? start_message : edit_message(@buffer.dup)
148
145
  end
149
146
 
150
- # Returns true when this streamer delivered the reply (the runner
151
- # then skips the completion post); false hands delivery back.
147
+ # Returns true when this streamer delivered the reply (the runner then skips the completion
148
+ # post); false hands delivery back.
152
149
  def finish(final_text)
153
150
  return false if @ts.nil?
154
151
 
@@ -196,8 +193,8 @@ module Xeno
196
193
  end
197
194
  end
198
195
 
199
- # One JSON POST to the Slack Web API; 429 becomes RateLimited (with
200
- # Retry-After), any other failure raises Xeno::Error.
196
+ # One JSON POST to the Slack Web API; 429 becomes RateLimited (with Retry-After), any other
197
+ # failure raises Xeno::Error.
201
198
  def api_post(method, payload)
202
199
  uri = URI("#{api_base}/#{method}")
203
200
  request = Net::HTTP::Post.new(uri)
@@ -242,14 +239,13 @@ module Xeno
242
239
  end
243
240
  end
244
241
 
245
- # A reply into a waiting thread resolves the FIRST pending input; an
246
- # unrelated reply is HELD as the next message (never auto-deny, never
247
- # a forced answer):
242
+ # A reply into a waiting thread resolves the first pending input;
243
+ # an unrelated reply is held as the next message — never an
244
+ # auto-deny, never a forced answer.
248
245
  # - approvals: approve/deny words resolve; anything else holds.
249
- # - questions with choices: match by 1-based index ("2"), by label
250
- # (case-insensitive), or by exact option text; no match holds and
251
- # the question stays pending.
252
- # - free-form questions (no choices): any text is the answer.
246
+ # - questions with choices: match by 1-based index, label, or exact
247
+ # option text; no match holds.
248
+ # - free-form questions: any text is the answer.
253
249
  def continue_session(session, text, principal)
254
250
  pending = Action.joins(:turn)
255
251
  .where(xeno_turns: { session_id: session.id }, status: "pending_approval")
data/lib/xeno/channels.rb CHANGED
@@ -1,16 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # The channel registry. A channel (a) normalizes platform input into user
3
- # messages, (b) owns the session's continuation token, and (c) decides
4
- # delivery of agent output. HTTP is built in; agent/channels/*.rb declare
5
- # the rest via the DSL:
4
+ # The channel registry. A channel normalizes platform input into user messages, owns the session's
5
+ # continuation token, and decides delivery of agent output. HTTP is built in; agent/channels/*.rb
6
+ # declare the rest:
6
7
  #
7
8
  # Xeno.channel :slack do
8
9
  # signing_secret Rails.application.credentials.dig(:slack, :signing_secret)
9
10
  # bot_token Rails.application.credentials.dig(:slack, :bot_token)
10
11
  # end
11
12
  #
12
- # Delivery is a failable nicety: the durable truth lives in the transcript
13
- # and event rows, so a failed post logs and moves on.
13
+ # Delivery is a failable nicety: the durable truth lives in the transcript and event rows, so a
14
+ # failed post logs and moves on.
14
15
  module Channels
15
16
  module_function
16
17
 
@@ -27,7 +28,7 @@ module Xeno
27
28
  when :slack
28
29
  Slack.new(&block)
29
30
  else
30
- raise Xeno::Error, "unknown channel type: #{name} (v0.1 ships :slack; http is built in)"
31
+ raise Xeno::Error, "unknown channel type: #{name} (:slack is bundled; http is built in)"
31
32
  end
32
33
  end
33
34
 
@@ -47,8 +48,8 @@ module Xeno
47
48
  Rails.logger.warn("xeno: channel input-request delivery failed for session #{session.id}: #{e.class}: #{e.message}")
48
49
  end
49
50
 
50
- # A per-reply delta sink (post-then-edit streaming) when the session's
51
- # channel opts in; nil otherwise. Failable nicety like all delivery.
51
+ # A per-reply delta sink (post-then-edit streaming) when the session's channel opts in; nil
52
+ # otherwise. Failable nicety like all delivery.
52
53
  def streamer_for(session)
53
54
  channel = for_session(session)
54
55
  return nil unless channel.respond_to?(:streamer_for)
@@ -1,20 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # Summarize-and-replace compaction, mixed into TurnRunner and executed as
3
- # a claimed turn (kind: "compaction"). Riding the turn machinery buys the
4
- # hard properties for free: the claim CAS makes it exclusive, session
5
- # ordering queues it behind an active or parked turn, the heartbeat covers
6
- # the summary model call, and a crash replays it (the applied result is
7
- # recorded on the turn row, so replay never compacts twice).
4
+ # Summarize-and-replace compaction, run as a claimed turn (kind: "compaction") so it is exclusive,
5
+ # queues behind active work, and replays safely after a crash.
8
6
  #
9
- # Shape: the system prompt survives untouched; the most recent
10
- # `compaction_tail_turns` user-anchored turns survive verbatim (a tail cut
11
- # at a user row can never separate an assistant's tool calls from their
12
- # results); everything between is summarized by a throwaway model call
13
- # (the active model, never persisted) and REPLACED — the summary is
14
- # written into the earliest compacted row so both id- and created_at-
15
- # ordering keep it in place, and the remaining rows are destroyed
16
- # newest-first (results before the tool_calls they reference).
17
- module Compaction
7
+ # The system prompt and the last `compaction_tail_turns` turns survive verbatim; everything before
8
+ # them is summarized by a throwaway model call and replaced in place.
9
+ module Compaction # :nodoc:
18
10
  SUMMARY_PREFIX = "[Conversation summary — earlier messages were compacted]".freeze
19
11
 
20
12
  COMPACTION_PROMPT = <<~PROMPT.freeze
@@ -50,8 +42,9 @@ module Xeno
50
42
  (turn.user_message || {}).key?("result")
51
43
  end
52
44
 
53
- # Everything strictly before the tail is compactable. The tail starts at
54
- # the Nth-from-last user row; with too few turns there is nothing to do.
45
+ # Everything strictly before the tail is compactable. The tail starts at the Nth-from-last user
46
+ # row — a cut at a user row never splits an assistant's tool calls from their results. With too
47
+ # few turns there is nothing to do.
55
48
  def compaction_plan(rows)
56
49
  system_rows, convo = rows.partition { |row| row.role == "system" }
57
50
  user_positions = convo.each_index.select { |i| convo[i].role == "user" }
@@ -65,9 +58,8 @@ module Xeno
65
58
  end
66
59
  end
67
60
 
68
- # One model call on a throwaway chat — the active model with the agent's
69
- # runtime options, no persistence, no tools. A previous summary (from an
70
- # earlier compaction) is just an early row here and flows in whole.
61
+ # One model call on a throwaway chat: no persistence, no tools. An earlier compaction's summary
62
+ # is an ordinary early row here.
71
63
  def compaction_summary(rows)
72
64
  options = definition.config.model_options
73
65
  llm = RubyLLM.chat(
@@ -92,17 +84,16 @@ module Xeno
92
84
  }.join("\n\n")
93
85
  end
94
86
 
95
- # The keeper (earliest compacted row) becomes the summary in place; the
96
- # rest are destroyed newest-first. Rows, marker, and the
97
- # compaction.completed event commit together — replay after any crash
98
- # is a pure no-op past this transaction.
87
+ # The earliest compacted row becomes the summary in place; the rest are destroyed newest-first.
88
+ # Rows, marker, and event commit together, so replay after a crash is a no-op past this
89
+ # transaction.
99
90
  def apply_compaction!(plan, summary)
100
91
  keeper, *rest = plan[:compact]
101
92
 
102
93
  ActiveRecord::Base.transaction do
103
94
  rest.reverse_each(&:destroy!)
104
- # A parent tool call on a surviving earlier row would otherwise
105
- # dangle a result FK at the repurposed keeper.
95
+ # A parent tool call on a surviving earlier row would otherwise dangle a result FK at the
96
+ # repurposed keeper.
106
97
  keeper.ruby_llm_tool_calls.destroy_all
107
98
  keeper.ruby_llm_parent_tool_call&.update!(result: nil)
108
99
  keeper.update!(
@@ -123,10 +114,8 @@ module Xeno
123
114
  end
124
115
  end
125
116
 
126
- # Between-turns trigger: after a message turn completes, stage a
127
- # compaction turn when the last model call's context usage crossed the
128
- # threshold share of the model's window (config fallback when the
129
- # registry doesn't know the window).
117
+ # After a message turn completes, stage a compaction turn when the last model call crossed the
118
+ # threshold share of the model's window (config fallback when the registry doesn't know it).
130
119
  def maybe_stage_compaction
131
120
  threshold = Xeno.config.compaction_threshold
132
121
  return unless threshold
@@ -151,9 +140,7 @@ module Xeno
151
140
  window || Xeno.config.compaction_context_window
152
141
  end
153
142
 
154
- # What the provider counted for the last completed call — the truthful
155
- # "how full is the context" number, from the per-attempt usage ledger
156
- # (message rows no longer carry token columns).
143
+ # The provider's count for the last completed call, read from the per-attempt usage ledger.
157
144
  def compaction_tokens_used
158
145
  last = @chat.ruby_llm_usages.where(operation: "chat", status: "succeeded")
159
146
  .where.not(input_tokens: nil).order(:created_at, :id).last