xeno 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE +21 -0
- data/README.md +211 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/xeno/application.css +15 -0
- data/app/controllers/xeno/api_controller.rb +68 -0
- data/app/controllers/xeno/application_controller.rb +4 -0
- data/app/controllers/xeno/dev_controller.rb +24 -0
- data/app/controllers/xeno/dev_ui_controller.rb +71 -0
- data/app/controllers/xeno/health_controller.rb +10 -0
- data/app/controllers/xeno/sessions_controller.rb +131 -0
- data/app/controllers/xeno/slack_controller.rb +48 -0
- data/app/controllers/xeno/streams_controller.rb +122 -0
- data/app/helpers/xeno/application_helper.rb +4 -0
- data/app/jobs/xeno/application_job.rb +4 -0
- data/app/jobs/xeno/reaper_job.rb +12 -0
- data/app/jobs/xeno/schedule_job.rb +56 -0
- data/app/jobs/xeno/slack_event_job.rb +20 -0
- data/app/jobs/xeno/turn_job.rb +16 -0
- data/app/mailers/xeno/application_mailer.rb +6 -0
- data/app/models/xeno/action.rb +26 -0
- data/app/models/xeno/application_record.rb +5 -0
- data/app/models/xeno/chat.rb +22 -0
- data/app/models/xeno/dedup.rb +24 -0
- data/app/models/xeno/event.rb +63 -0
- data/app/models/xeno/message.rb +5 -0
- data/app/models/xeno/pending_message.rb +7 -0
- data/app/models/xeno/session.rb +231 -0
- data/app/models/xeno/turn.rb +125 -0
- data/app/views/layouts/xeno/application.html.erb +18 -0
- data/app/views/xeno/dev_ui/_styles.html.erb +24 -0
- data/app/views/xeno/dev_ui/index.html.erb +28 -0
- data/app/views/xeno/dev_ui/show.html.erb +115 -0
- data/config/routes.rb +25 -0
- data/db/migrate/20260804000001_create_xeno_llm_tables.rb +70 -0
- data/db/migrate/20260804000002_create_xeno_orchestration_tables.rb +70 -0
- data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +8 -0
- data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +8 -0
- data/db/migrate/20260805000003_create_xeno_dedups.rb +14 -0
- data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +9 -0
- data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +8 -0
- data/db/migrate/20260806000001_move_transcript_support_tables_to_ruby_llm.rb +133 -0
- data/docs/runtime.md +275 -0
- data/exe/xeno +133 -0
- data/lib/generators/xeno/install/install_generator.rb +51 -0
- data/lib/generators/xeno/install/templates/agent.rb +4 -0
- data/lib/generators/xeno/install/templates/initializer.rb +20 -0
- data/lib/generators/xeno/install/templates/instructions.md +6 -0
- data/lib/generators/xeno/tool/templates/tool.rb.tt +16 -0
- data/lib/generators/xeno/tool/tool_generator.rb +13 -0
- data/lib/tasks/xeno_tasks.rake +24 -0
- data/lib/xeno/agent_config.rb +66 -0
- data/lib/xeno/agent_definition.rb +286 -0
- data/lib/xeno/approval_context.rb +4 -0
- data/lib/xeno/arguments.rb +62 -0
- data/lib/xeno/ask_question.rb +18 -0
- data/lib/xeno/channels/slack.rb +311 -0
- data/lib/xeno/channels.rb +68 -0
- data/lib/xeno/compaction.rb +165 -0
- data/lib/xeno/configuration.rb +118 -0
- data/lib/xeno/engine.rb +29 -0
- data/lib/xeno/errors.rb +40 -0
- data/lib/xeno/hooks.rb +37 -0
- data/lib/xeno/info.rb +75 -0
- data/lib/xeno/inputs.rb +78 -0
- data/lib/xeno/reaper.rb +52 -0
- data/lib/xeno/schedules.rb +49 -0
- data/lib/xeno/session_state.rb +57 -0
- data/lib/xeno/standalone/local_secret.rb +26 -0
- data/lib/xeno/standalone/model_refresh.rb +26 -0
- data/lib/xeno/standalone/puma.rb +17 -0
- data/lib/xeno/standalone.rb +136 -0
- data/lib/xeno/tool.rb +73 -0
- data/lib/xeno/turn_runner.rb +545 -0
- data/lib/xeno/version.rb +3 -0
- data/lib/xeno.rb +117 -0
- metadata +151 -0
|
@@ -0,0 +1,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
|