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,125 @@
1
+ module Xeno
2
+ # One user message and all work until the agent responds. Runs as one
3
+ # ActiveJob. Correctness lives here, in the database, not in the queue:
4
+ #
5
+ # 1. Atomic claim (CAS on claim_token) — one worker owns the turn.
6
+ # 2. Heartbeat — the owner touches heartbeat_at at step boundaries; a
7
+ # stale heartbeat lets the next attempt reclaim (checkpoint replay
8
+ # makes takeover safe).
9
+ # 3. Fencing token — every write is conditioned on claim_token = mine, so
10
+ # a zombie that wakes up after being reaped affects zero rows.
11
+ # 4. Max attempts — poison turns become turn.failed, not infinite retries.
12
+ class Turn < ApplicationRecord
13
+ STATUSES = %w[pending running waiting completed failed cancelled].freeze
14
+ KINDS = %w[message compaction].freeze
15
+
16
+ belongs_to :session, class_name: "Xeno::Session"
17
+ has_many :actions, class_name: "Xeno::Action", dependent: :destroy
18
+
19
+ validates :status, inclusion: { in: STATUSES }
20
+ validates :kind, inclusion: { in: KINDS }
21
+
22
+ # Appends the next turn to the session, sequence assigned race-safely.
23
+ # The INSERT runs in its own savepoint (requires_new): stage_turn! calls
24
+ # this inside a transaction, and on Postgres a failed INSERT otherwise
25
+ # aborts the whole transaction — the retry would raise
26
+ # PG::InFailedSqlTransaction instead of recovering.
27
+ def self.append!(session, user_message:, transcript_deferred: false, kind: "message")
28
+ attempts = 0
29
+ begin
30
+ sequence = (where(session_id: session.id).maximum(:sequence) || 0) + 1
31
+ transaction(requires_new: true) do
32
+ create!(session: session, sequence: sequence, user_message: user_message,
33
+ transcript_deferred: transcript_deferred, kind: kind)
34
+ end
35
+ rescue ActiveRecord::RecordNotUnique
36
+ attempts += 1
37
+ retry if attempts < 5
38
+ raise
39
+ end
40
+ end
41
+
42
+ def enqueue!
43
+ TurnJob.perform_later(id)
44
+ end
45
+
46
+ # The atomic claim. Returns the fencing token on success, nil when
47
+ # another worker owns the turn (exit quietly) or it isn't claimable.
48
+ # Claimable: pending, or running with a stale heartbeat (dead owner).
49
+ # Poison turns are failed here instead of retrying forever.
50
+ #
51
+ # `attempts` counts FAILURES, not claims: a stale-running reclaim is
52
+ # crash evidence and counts here; a transient error counts in
53
+ # release_for_retry!; an approval/question resume counts in `resumes`
54
+ # and never against the poison ladder (H2).
55
+ def claim!
56
+ current = self.class.find(id)
57
+
58
+ if current.attempts >= Xeno.config.max_turn_attempts
59
+ poisoned = self.class.where(id: id, claim_token: current.claim_token)
60
+ .where.not(status: %w[completed failed cancelled])
61
+ .update_all(status: "failed", error: { "message" => "max attempts (#{current.attempts}) exhausted" })
62
+ if poisoned == 1
63
+ # A poisoned TURN leaves the SESSION running (idle, can take the
64
+ # next message); settle any dangling tool calls so it stays usable.
65
+ session.settle_unanswered_tool_calls!(self, reason: "turn failed (max attempts exhausted)")
66
+ session.emit("turn.failed", { turn_id: id, error: "max attempts exhausted" })
67
+ end
68
+ return nil
69
+ end
70
+
71
+ stale_before = Xeno.config.turn_stale_after.ago
72
+ earlier_active = self.class
73
+ .where(session_id: session_id)
74
+ .where("sequence < ?", sequence)
75
+ .where(status: %w[pending running waiting])
76
+
77
+ claimed = self.class
78
+ .where(id: id, claim_token: current.claim_token)
79
+ .where("status = 'pending' OR (status = 'running' AND (heartbeat_at IS NULL OR heartbeat_at < ?))", stale_before)
80
+ .where.not(earlier_active.arel.exists) # turns run in session order
81
+ .update_all([
82
+ "status = 'running', claim_token = ?, " \
83
+ "attempts = attempts + (CASE WHEN status = 'running' THEN 1 ELSE 0 END), " \
84
+ "heartbeat_at = ?",
85
+ current.claim_token + 1, Time.current
86
+ ])
87
+
88
+ claimed == 1 ? current.claim_token + 1 : nil
89
+ end
90
+
91
+ # Transient failure: record the error, count the failure against the
92
+ # poison ladder, and hand the claim back (status pending) so the queue
93
+ # retry can claim immediately.
94
+ def release_for_retry!(token, error)
95
+ current = self.class.find(id)
96
+ fenced_update!(token,
97
+ status: "pending",
98
+ attempts: current.attempts + 1,
99
+ error: { "class" => error.class.name, "message" => error.message })
100
+ end
101
+
102
+ # Fenced write: touches the heartbeat iff we still own the claim.
103
+ # Raises Xeno::Fenced when a zombie discovers it was reaped.
104
+ def heartbeat!(token)
105
+ fenced_update!(token, heartbeat_at: Time.current)
106
+ end
107
+
108
+ def fenced_update!(token, attributes)
109
+ rows = self.class.where(id: id, claim_token: token).update_all(attributes)
110
+ raise Xeno::Fenced, "turn #{id}: claim #{token} was fenced out" unless rows == 1
111
+
112
+ true
113
+ end
114
+
115
+ def record_step!(token)
116
+ current = self.class.find(id)
117
+ if current.steps_count >= Xeno.config.max_steps
118
+ raise Xeno::MaxStepsExceeded, "turn #{id}: exceeded #{Xeno.config.max_steps} steps"
119
+ end
120
+
121
+ fenced_update!(token, steps_count: current.steps_count + 1, heartbeat_at: Time.current)
122
+ current.steps_count + 1
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Xeno</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%# The dev UI inlines its styles (_styles partial) — no asset-pipeline
9
+ link: standalone/mounted apps without propshaft would 404 on
10
+ /stylesheets/xeno/application.css on every page load. %>
11
+ <%= yield :head %>
12
+ </head>
13
+ <body>
14
+
15
+ <%= yield %>
16
+
17
+ </body>
18
+ </html>
@@ -0,0 +1,24 @@
1
+ <style>
2
+ .xeno-dev { max-width: 960px; margin: 2rem auto; font-family: ui-sans-serif, system-ui, sans-serif; color: #1a1a1a; padding: 0 1rem; }
3
+ .xeno-dev h1 { font-size: 1.4rem; } .xeno-dev h2 { font-size: 1.05rem; margin-top: 1.5rem; }
4
+ .xeno-dev .muted { color: #888; }
5
+ .xeno-dev table { border-collapse: collapse; width: 100%; }
6
+ .xeno-dev th, .xeno-dev td { text-align: left; padding: .35rem .6rem; border-bottom: 1px solid #eee; font-size: .9rem; }
7
+ .xeno-dev .badge { font-size: .75rem; padding: .1rem .5rem; border-radius: 99px; background: #eee; vertical-align: middle; }
8
+ .xeno-dev .badge-running { background: #d9f0ff; } .xeno-dev .badge-waiting { background: #fff3cd; }
9
+ .xeno-dev .badge-completed { background: #d9f7dc; } .xeno-dev .badge-failed { background: #ffd9d9; }
10
+ .xeno-dev .columns { display: grid; grid-template-columns: 3fr 2fr; gap: 2rem; align-items: start; }
11
+ .xeno-dev .msg { margin: .6rem 0; padding: .5rem .8rem; border-radius: 8px; background: #f6f6f6; }
12
+ .xeno-dev .msg-user { background: #eef4ff; } .xeno-dev .msg-tool { background: #f2fbf2; }
13
+ .xeno-dev .msg .role { font-size: .7rem; text-transform: uppercase; color: #999; margin-bottom: .15rem; }
14
+ .xeno-dev .msg code { font-size: .82rem; display: block; margin: .15rem 0; }
15
+ .xeno-dev .pending { border: 2px solid #f0c36d; border-radius: 8px; padding: .6rem .9rem; margin-top: 1rem; background: #fffaf0; }
16
+ .xeno-dev .pending h3 { margin: .2rem 0 .5rem; font-size: .95rem; }
17
+ .xeno-dev form.inline { display: inline-block; margin-right: .4rem; }
18
+ .xeno-dev form input[type="text"] { width: 60%; padding: .45rem .6rem; border: 1px solid #ccc; border-radius: 6px; }
19
+ .xeno-dev form input[type="submit"] { padding: .45rem .9rem; border: 0; border-radius: 6px; background: #1a1a1a; color: #fff; cursor: pointer; }
20
+ .xeno-dev form input.deny { background: #b33; }
21
+ .xeno-dev .events ol { font-size: .82rem; line-height: 1.5; padding-left: 2.4rem; }
22
+ .xeno-dev .live { color: #2b2; font-size: .8rem; }
23
+ .xeno-dev .reply { margin-top: 1rem; }
24
+ </style>
@@ -0,0 +1,28 @@
1
+ <div class="xeno-dev">
2
+ <h1>xeno · dev chat</h1>
3
+
4
+ <%= form_with url: dev_sessions_path, method: :post, local: true, class: "new-chat" do |f| %>
5
+ <%= f.text_field :message, placeholder: "Start a conversation…", required: true, autofocus: true %>
6
+ <%= f.submit "Send" %>
7
+ <% end %>
8
+
9
+ <h2>Sessions</h2>
10
+ <% if @sessions.any? %>
11
+ <table>
12
+ <tr><th>#</th><th>status</th><th>channel</th><th>started</th><th></th></tr>
13
+ <% @sessions.each do |session| %>
14
+ <tr>
15
+ <td>#<%= session.id %></td>
16
+ <td><span class="badge badge-<%= session.status %>"><%= session.status %></span></td>
17
+ <td><%= session.channel %></td>
18
+ <td><%= session.created_at.strftime("%b %-d %H:%M") %></td>
19
+ <td><%= link_to "open", dev_session_path(session) %></td>
20
+ </tr>
21
+ <% end %>
22
+ </table>
23
+ <% else %>
24
+ <p class="muted">No sessions yet — send a message above.</p>
25
+ <% end %>
26
+ </div>
27
+
28
+ <%= render "xeno/dev_ui/styles" %>
@@ -0,0 +1,115 @@
1
+ <div class="xeno-dev">
2
+ <p><%= link_to "← sessions", dev_ui_path %></p>
3
+ <h1>
4
+ session #<%= @session.id %>
5
+ <span class="badge badge-<%= @session.status %>"><%= @session.status %></span>
6
+ </h1>
7
+
8
+ <div class="columns">
9
+ <section class="chat">
10
+ <h2>Conversation</h2>
11
+ <% @messages.each do |message| %>
12
+ <% next if message.role == "system" %>
13
+ <div class="msg msg-<%= message.role %>">
14
+ <div class="role"><%= message.role %></div>
15
+ <% if message.tool_call? %>
16
+ <% message.ruby_llm_tool_calls.each do |call| %>
17
+ <code>→ <%= call.name %>(<%= call.arguments.to_json %>)</code>
18
+ <% end %>
19
+ <% end %>
20
+ <% if message.content.present? %>
21
+ <div class="content"><%= simple_format(message.content) %></div>
22
+ <% end %>
23
+ </div>
24
+ <% end %>
25
+
26
+ <% if @pending_actions.any? %>
27
+ <div class="pending">
28
+ <h3>⏸ waiting on you</h3>
29
+ <% @pending_actions.each do |action| %>
30
+ <div class="action">
31
+ <% if action.kind == "question" %>
32
+ <p><strong><%= action.input&.dig("question") %></strong></p>
33
+ <%= form_with url: dev_session_input_path(@session), method: :post, local: true do |f| %>
34
+ <%= f.hidden_field :action_id, value: action.id %>
35
+ <%= f.text_field :answer, placeholder: "Your answer…", required: true %>
36
+ <%= f.submit "Answer" %>
37
+ <% end %>
38
+ <% else %>
39
+ <p><code><%= action.tool_name %>(<%= action.input.to_json %>)</code></p>
40
+ <%= form_with url: dev_session_input_path(@session), method: :post, local: true, class: "inline" do |f| %>
41
+ <%= f.hidden_field :action_id, value: action.id %>
42
+ <%= f.hidden_field :decision, value: "approve" %>
43
+ <%= f.submit "Approve" %>
44
+ <% end %>
45
+ <%= form_with url: dev_session_input_path(@session), method: :post, local: true, class: "inline" do |f| %>
46
+ <%= f.hidden_field :action_id, value: action.id %>
47
+ <%= f.hidden_field :decision, value: "deny" %>
48
+ <%= f.submit "Deny", class: "deny" %>
49
+ <% end %>
50
+ <% end %>
51
+ </div>
52
+ <% end %>
53
+ </div>
54
+ <% end %>
55
+
56
+ <% if @session.active? %>
57
+ <%= form_with url: dev_session_message_path(@session), method: :post, local: true, class: "reply" do |f| %>
58
+ <%= f.text_field :message, placeholder: "Reply…", required: true %>
59
+ <%= f.submit "Send" %>
60
+ <label title="Stop the active turn and make this message the next one">
61
+ <%= f.check_box :steer %> steer
62
+ </label>
63
+ <% end %>
64
+ <%= form_with url: dev_session_compact_path(@session), method: :post, local: true, class: "inline" do |f| %>
65
+ <%= f.submit "Compact transcript", title: "Summarize-and-replace everything but the recent turns" %>
66
+ <% end %>
67
+ <% else %>
68
+ <p class="muted">Session is <%= @session.status %>.</p>
69
+ <% end %>
70
+ </section>
71
+
72
+ <section class="events">
73
+ <h2>Event stream <span class="live" id="live-dot">●</span></h2>
74
+ <ol id="event-list" start="0">
75
+ <% @events.each do |event| %>
76
+ <li value="<%= event.index %>"><code><%= event.event_type %></code></li>
77
+ <% end %>
78
+ </ol>
79
+ </section>
80
+ </div>
81
+ </div>
82
+
83
+ <%= render "xeno/dev_ui/styles" %>
84
+
85
+ <script>
86
+ // Live updates ride xeno's own SSE endpoint — the dev UI dogfoods the API.
87
+ (function () {
88
+ var nextIndex = <%= (@events.last&.index || -1) + 1 %>;
89
+ var source = new EventSource("<%= session_stream_path(@session, start_index: (@events.last&.index || -1) + 1) %>");
90
+ var list = document.getElementById("event-list");
91
+ var reloadTypes = ["turn.completed", "turn.failed", "turn.cancelled", "session.waiting", "message.completed"];
92
+
93
+ ["session.started", "message.received", "turn.started", "step.started", "step.completed",
94
+ "actions.requested", "action.result", "input.requested", "reasoning.completed",
95
+ "message.completed", "compaction.requested", "compaction.completed",
96
+ "budget.exceeded", "turn.completed", "turn.failed", "turn.cancelled",
97
+ "session.waiting", "session.completed", "session.failed"].forEach(function (type) {
98
+ source.addEventListener(type, function (event) {
99
+ var li = document.createElement("li");
100
+ var envelope = JSON.parse(event.data);
101
+ li.value = envelope.meta.index;
102
+ li.innerHTML = "<code>" + type + "</code>";
103
+ list.appendChild(li);
104
+ if (reloadTypes.indexOf(type) !== -1) {
105
+ source.close();
106
+ setTimeout(function () { location.reload(); }, 300);
107
+ }
108
+ });
109
+ });
110
+
111
+ source.onerror = function () {
112
+ document.getElementById("live-dot").style.color = "#999";
113
+ };
114
+ })();
115
+ </script>
data/config/routes.rb ADDED
@@ -0,0 +1,25 @@
1
+ Xeno::Engine.routes.draw do
2
+ scope "v1" do
3
+ get "health", to: "health#show"
4
+
5
+ post "sessions", to: "sessions#create"
6
+ post "sessions/:id/messages", to: "sessions#message", as: :session_messages
7
+ post "sessions/:id/inputs", to: "sessions#input", as: :session_inputs
8
+ post "sessions/:id/cancel", to: "sessions#cancel", as: :session_cancel
9
+ post "sessions/:id/compact", to: "sessions#compact", as: :session_compact
10
+ post "sessions/:id/reset", to: "sessions#reset", as: :session_reset
11
+ get "sessions/:id/stream", to: "streams#show", as: :session_stream
12
+
13
+ post "dev/schedules/:name", to: "dev#dispatch_schedule", as: :dev_schedule_dispatch
14
+
15
+ post "channels/slack/events", to: "slack#events", as: :slack_events
16
+ end
17
+
18
+ # The dev chat UI (development only; the controller 404s elsewhere).
19
+ get "dev", to: "dev_ui#index", as: :dev_ui
20
+ post "dev/sessions", to: "dev_ui#create", as: :dev_sessions
21
+ get "dev/sessions/:id", to: "dev_ui#show", as: :dev_session
22
+ post "dev/sessions/:id/messages", to: "dev_ui#message", as: :dev_session_message
23
+ post "dev/sessions/:id/inputs", to: "dev_ui#input", as: :dev_session_input
24
+ post "dev/sessions/:id/compact", to: "dev_ui#compact", as: :dev_session_compact
25
+ end
@@ -0,0 +1,70 @@
1
+ # The transcript layer: xeno-owned copies of RubyLLM's acts_as tables
2
+ # (chats, messages, tool_calls, models). Column set mirrors RubyLLM's
3
+ # install generator templates so the acts_as persistence paths keep
4
+ # every column they know how to write (thinking, tokens, costs).
5
+ class CreateXenoLlmTables < ActiveRecord::Migration[8.1]
6
+ def change
7
+ create_table :xeno_models do |t|
8
+ t.string :model_id, null: false
9
+ t.string :name, null: false
10
+ t.string :provider, null: false
11
+ t.string :family
12
+ t.datetime :model_created_at
13
+ t.integer :context_window
14
+ t.integer :max_output_tokens
15
+ t.date :knowledge_cutoff
16
+ t.json :modalities, default: {}
17
+ t.json :capabilities, default: []
18
+ t.json :pricing, default: {}
19
+ t.json :metadata, default: {}
20
+ t.timestamps
21
+
22
+ t.index [ :provider, :model_id ], unique: true
23
+ t.index :provider
24
+ t.index :family
25
+ end
26
+
27
+ create_table :xeno_chats do |t|
28
+ t.boolean :cancelled, null: false, default: false
29
+ t.references :model, foreign_key: { to_table: :xeno_models }
30
+ t.timestamps
31
+ end
32
+
33
+ create_table :xeno_messages do |t|
34
+ t.string :role, null: false
35
+ t.text :content
36
+ t.boolean :cache_until_here, null: false, default: false
37
+ t.text :thinking_text
38
+ t.text :thinking_signature
39
+ t.integer :thinking_tokens
40
+ t.json :citations
41
+ t.integer :input_tokens
42
+ t.integer :output_tokens
43
+ t.integer :cache_read_tokens
44
+ t.integer :cache_write_tokens
45
+ t.decimal :total_cost, precision: 16, scale: 10
46
+ t.json :cost_details
47
+ t.string :finish_reason
48
+ t.references :chat, null: false, foreign_key: { to_table: :xeno_chats }
49
+ t.references :model, foreign_key: { to_table: :xeno_models }
50
+ t.timestamps
51
+
52
+ t.index :role
53
+ end
54
+
55
+ create_table :xeno_tool_calls do |t|
56
+ t.string :tool_call_id, null: false
57
+ t.string :name, null: false
58
+ t.text :thought_signature
59
+ t.json :arguments, default: {}
60
+ t.references :message, null: false, foreign_key: { to_table: :xeno_messages }
61
+ t.timestamps
62
+
63
+ t.index :tool_call_id, unique: true
64
+ t.index :name
65
+ end
66
+
67
+ # Tool-result messages point back at the tool call they answer.
68
+ add_reference :xeno_messages, :tool_call, foreign_key: { to_table: :xeno_tool_calls }
69
+ end
70
+ end
@@ -0,0 +1,70 @@
1
+ # The orchestration layer above the transcript: sessions, turns (claim/
2
+ # heartbeat/fencing), actions (tool-call checkpoint + approval state),
3
+ # events (the append-only session stream), pending messages (drained into
4
+ # the next turn). Per spike #2 there is no steps table — a step's output
5
+ # lives in xeno_messages/xeno_tool_calls.
6
+ class CreateXenoOrchestrationTables < ActiveRecord::Migration[8.1]
7
+ def change
8
+ create_table :xeno_sessions do |t|
9
+ t.string :agent, null: false
10
+ t.string :status, null: false, default: "running" # running|waiting|completed|failed
11
+ t.string :channel
12
+ t.string :continuation_token
13
+ t.string :title
14
+ t.json :principal
15
+ t.json :metadata, default: {}
16
+ t.references :chat, null: false, foreign_key: { to_table: :xeno_chats }
17
+ t.timestamps
18
+
19
+ t.index :continuation_token, unique: true
20
+ t.index :status
21
+ end
22
+
23
+ create_table :xeno_turns do |t|
24
+ t.references :session, null: false, foreign_key: { to_table: :xeno_sessions }
25
+ t.integer :sequence, null: false
26
+ t.string :status, null: false, default: "pending" # pending|running|waiting|completed|failed|cancelled
27
+ t.integer :claim_token, null: false, default: 0
28
+ t.datetime :heartbeat_at
29
+ t.integer :attempts, null: false, default: 0
30
+ t.integer :steps_count, null: false, default: 0
31
+ t.json :user_message
32
+ t.json :error
33
+ t.timestamps
34
+
35
+ t.index [ :session_id, :sequence ], unique: true
36
+ t.index :status
37
+ end
38
+
39
+ create_table :xeno_actions do |t|
40
+ t.references :turn, null: false, foreign_key: { to_table: :xeno_turns }
41
+ t.string :tool_call_id, null: false # the provider call id (xeno_tool_calls.tool_call_id)
42
+ t.string :tool_name, null: false
43
+ t.string :kind, null: false, default: "tool" # tool|question
44
+ t.string :status, null: false, default: "pending" # pending|pending_approval|approved|denied|completed|failed
45
+ t.json :input
46
+ t.json :output
47
+ t.datetime :resolved_at # when a human approved/denied/answered
48
+ t.json :resolved_by # the resolving principal
49
+ t.timestamps
50
+
51
+ t.index [ :turn_id, :tool_call_id ], unique: true
52
+ end
53
+
54
+ create_table :xeno_events do |t|
55
+ t.references :session, null: false, foreign_key: { to_table: :xeno_sessions }
56
+ t.integer :index, null: false # per-session cursor (SSE start_index)
57
+ t.string :event_type, null: false
58
+ t.json :data
59
+ t.datetime :created_at, null: false
60
+
61
+ t.index [ :session_id, :index ], unique: true
62
+ end
63
+
64
+ create_table :xeno_pending_messages do |t|
65
+ t.references :session, null: false, foreign_key: { to_table: :xeno_sessions }
66
+ t.json :payload, null: false
67
+ t.timestamps
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,8 @@
1
+ # H2: `attempts` is the failure ladder (transient errors, crash reclaims) —
2
+ # approval/question resumes are human-driven and unbounded, counted apart so
3
+ # a much-approved turn can never trip the poison branch.
4
+ class AddResumesToXenoTurns < ActiveRecord::Migration[8.1]
5
+ def change
6
+ add_column :xeno_turns, :resumes, :integer, null: false, default: 0
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ class AddTranscriptDeferredToXenoTurns < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # A deferred turn's user messages live in turns.user_message until the
4
+ # runner claims it and writes them into the transcript (drained turns
5
+ # must not mutate a parked turn's mid-flight transcript).
6
+ add_column :xeno_turns, :transcript_deferred, :boolean, null: false, default: false
7
+ end
8
+ end
@@ -0,0 +1,14 @@
1
+ class CreateXenoDedups < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # At-least-once delivery dedup ledger: schedule ticks, Slack event ids.
4
+ # One row per (scope, key); the unique index is the arbiter.
5
+ create_table :xeno_dedups do |t|
6
+ t.string :scope, null: false
7
+ t.string :key, null: false
8
+ t.json :metadata
9
+ t.datetime :created_at, null: false
10
+
11
+ t.index [ :scope, :key ], unique: true
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,9 @@
1
+ class AddKindToXenoTurns < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # "message" turns drive the model loop; "compaction" turns
4
+ # summarize-and-replace the transcript. Compaction rides the turn
5
+ # machinery so it inherits the claim CAS, session ordering (it queues
6
+ # behind an active or parked turn), heartbeat, and crash-safe replay.
7
+ add_column :xeno_turns, :kind, :string, null: false, default: "message"
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ class AddStateToXenoSessions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # The session-scoped KV store (Session#state / Tool#state): JSON-typed
4
+ # values, survives restarts, never crosses sessions, cleared by reset.
5
+ # Working state for one conversation — NOT long-term memory.
6
+ add_column :xeno_sessions, :state, :json, null: false, default: {}
7
+ end
8
+ end
@@ -0,0 +1,133 @@
1
+ # ruby_llm 2aaddf96: models, tool calls, usage, and batches become library
2
+ # records on RubyLLM-owned ruby_llm_* tables (polymorphic toward the app's
3
+ # chat/message classes); message rows stop carrying token/cost accounting
4
+ # (the per-attempt ruby_llm_usage_entries ledger replaces it). Table shapes
5
+ # mirror upstream's install templates.
6
+ #
7
+ # DESTRUCTIVE (pre-release, no installs to migrate): xeno_tool_calls and
8
+ # xeno_models are dropped without moving their rows, and the accounting
9
+ # columns on xeno_messages are removed with their data. Existing chats keep
10
+ # their transcript text but lose tool-call linkage and usage history.
11
+ class MoveTranscriptSupportTablesToRubyLlm < ActiveRecord::Migration[8.1]
12
+ def change
13
+ create_table :ruby_llm_models do |t|
14
+ t.string :model_id, null: false
15
+ t.string :name, null: false
16
+ t.string :provider, null: false
17
+ t.string :family
18
+ t.datetime :model_created_at
19
+ t.integer :context_window
20
+ t.integer :max_output_tokens
21
+ t.date :knowledge_cutoff
22
+ t.json :modalities, default: {}
23
+ t.json :capabilities, default: []
24
+ t.json :pricing, default: {}
25
+ t.json :metadata, default: {}
26
+ t.timestamps
27
+
28
+ t.index [ :provider, :model_id ], unique: true
29
+ t.index :provider
30
+ t.index :family
31
+ end
32
+
33
+ create_table :ruby_llm_tool_calls do |t|
34
+ t.references :message, polymorphic: true, null: false, index: false
35
+ t.references :result, polymorphic: true, index: false
36
+ t.string :tool_call_id, null: false
37
+ t.string :name, null: false
38
+ t.text :thought_signature
39
+ t.json :arguments, default: {}
40
+ t.timestamps
41
+
42
+ t.index [ :message_type, :message_id ]
43
+ t.index [ :result_type, :result_id ]
44
+ t.index :tool_call_id, unique: true
45
+ t.index :name
46
+ end
47
+
48
+ create_table :ruby_llm_usage_entries do |t|
49
+ t.references :chat, polymorphic: true, null: false, index: false
50
+ t.references :message, polymorphic: true, index: false
51
+ t.string :operation, null: false
52
+ t.string :provider, null: false
53
+ t.string :model, null: false
54
+ t.string :status, null: false
55
+ t.integer :input_tokens
56
+ t.integer :output_tokens
57
+ t.integer :cache_read_tokens
58
+ t.integer :cache_write_tokens
59
+ t.integer :thinking_tokens
60
+ t.decimal :input_cost, precision: 16, scale: 10
61
+ t.decimal :output_cost, precision: 16, scale: 10
62
+ t.decimal :cache_read_cost, precision: 16, scale: 10
63
+ t.decimal :cache_write_cost, precision: 16, scale: 10
64
+ t.decimal :thinking_cost, precision: 16, scale: 10
65
+ t.decimal :total_cost, precision: 16, scale: 10
66
+ t.timestamps
67
+
68
+ t.index [ :chat_type, :chat_id ]
69
+ t.index [ :message_type, :message_id ]
70
+ t.index :status
71
+ t.check_constraint "operation IN ('chat', 'embedding', 'moderation', 'image', 'speech', 'transcription')"
72
+ t.check_constraint "status IN ('pending', 'succeeded', 'failed', 'cancelled')"
73
+ end
74
+
75
+ create_table :ruby_llm_batches do |t|
76
+ t.string :provider_batch_id, null: false
77
+ t.string :provider, null: false
78
+ t.string :status
79
+ t.boolean :completed, null: false, default: false
80
+ t.string :chat_type
81
+ t.string :batch_protocol
82
+ t.json :chat_ids, default: []
83
+ t.json :request_counts
84
+ t.timestamps
85
+
86
+ t.index [ :provider, :provider_batch_id ], unique: true
87
+ t.index :status
88
+ end
89
+
90
+ # acts_as_chat hard-wires belongs_to :model with this foreign key.
91
+ # Upstream's fresh-install template makes it null: false; here it stays
92
+ # nullable because pre-existing xeno_chats rows have nothing to point at
93
+ # (the association is optional and resolve_model fills it on save).
94
+ add_reference :xeno_chats, :ruby_llm_model, foreign_key: { to_table: :ruby_llm_models }
95
+
96
+ remove_reference :xeno_messages, :tool_call, foreign_key: { to_table: :xeno_tool_calls }
97
+ remove_reference :xeno_messages, :model, foreign_key: { to_table: :xeno_models }
98
+ remove_reference :xeno_chats, :model, foreign_key: { to_table: :xeno_models }
99
+
100
+ remove_column :xeno_messages, :thinking_tokens, :integer
101
+ remove_column :xeno_messages, :input_tokens, :integer
102
+ remove_column :xeno_messages, :output_tokens, :integer
103
+ remove_column :xeno_messages, :cache_read_tokens, :integer
104
+ remove_column :xeno_messages, :cache_write_tokens, :integer
105
+ remove_column :xeno_messages, :total_cost, :decimal, precision: 16, scale: 10
106
+ remove_column :xeno_messages, :cost_details, :json
107
+
108
+ drop_table :xeno_tool_calls do |t|
109
+ t.string :tool_call_id, null: false
110
+ t.string :name, null: false
111
+ t.text :thought_signature
112
+ t.json :arguments, default: {}
113
+ t.references :message, null: false
114
+ t.timestamps
115
+ end
116
+
117
+ drop_table :xeno_models do |t|
118
+ t.string :model_id, null: false
119
+ t.string :name, null: false
120
+ t.string :provider, null: false
121
+ t.string :family
122
+ t.datetime :model_created_at
123
+ t.integer :context_window
124
+ t.integer :max_output_tokens
125
+ t.date :knowledge_cutoff
126
+ t.json :modalities, default: {}
127
+ t.json :capabilities, default: []
128
+ t.json :pricing, default: {}
129
+ t.json :metadata, default: {}
130
+ t.timestamps
131
+ end
132
+ end
133
+ end