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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -2
- data/README.md +3 -1
- data/Rakefile +2 -0
- data/app/controllers/xeno/api_controller.rb +15 -18
- data/app/controllers/xeno/application_controller.rb +2 -0
- data/app/controllers/xeno/dev_controller.rb +5 -4
- data/app/controllers/xeno/dev_ui_controller.rb +5 -4
- data/app/controllers/xeno/health_controller.rb +3 -1
- data/app/controllers/xeno/sessions_controller.rb +22 -24
- data/app/controllers/xeno/slack_controller.rb +9 -10
- data/app/controllers/xeno/streams_controller.rb +18 -22
- data/app/helpers/xeno/application_helper.rb +2 -0
- data/app/jobs/xeno/application_job.rb +2 -0
- data/app/jobs/xeno/reaper_job.rb +5 -4
- data/app/jobs/xeno/schedule_job.rb +10 -13
- data/app/jobs/xeno/slack_event_job.rb +7 -7
- data/app/jobs/xeno/turn_job.rb +5 -4
- data/app/mailers/xeno/application_mailer.rb +2 -0
- data/app/models/xeno/action.rb +7 -12
- data/app/models/xeno/application_record.rb +2 -0
- data/app/models/xeno/chat.rb +9 -11
- data/app/models/xeno/dedup.rb +7 -7
- data/app/models/xeno/event.rb +16 -20
- data/app/models/xeno/message.rb +2 -0
- data/app/models/xeno/pending_message.rb +4 -2
- data/app/models/xeno/session.rb +52 -67
- data/app/models/xeno/turn.rb +29 -34
- data/config/routes.rb +2 -0
- data/db/migrate/{20260806000001_move_transcript_support_tables_to_ruby_llm.rb → 20260808000001_create_ruby_llm_tables.rb} +25 -54
- data/db/migrate/{20260804000002_create_xeno_orchestration_tables.rb → 20260808000002_create_xeno_tables.rb} +44 -14
- data/docs/configuration.md +56 -0
- data/docs/runtime.md +4 -4
- data/exe/xeno +1 -1
- data/lib/generators/xeno/install/install_generator.rb +3 -2
- data/lib/generators/xeno/install/templates/agent.rb +1 -2
- data/lib/generators/xeno/install/templates/initializer.rb +6 -9
- data/lib/generators/xeno/tool/tool_generator.rb +4 -2
- data/lib/tasks/xeno_tasks.rake +2 -0
- data/lib/xeno/agent_config.rb +9 -8
- data/lib/xeno/agent_definition.rb +25 -28
- data/lib/xeno/approval_context.rb +2 -0
- data/lib/xeno/arguments.rb +8 -9
- data/lib/xeno/ask_question.rb +5 -5
- data/lib/xeno/channels/slack.rb +25 -29
- data/lib/xeno/channels.rb +10 -9
- data/lib/xeno/compaction.rb +20 -33
- data/lib/xeno/configuration.rb +35 -44
- data/lib/xeno/engine.rb +8 -7
- data/lib/xeno/errors.rb +11 -10
- data/lib/xeno/hooks.rb +7 -10
- data/lib/xeno/info.rb +3 -2
- data/lib/xeno/inputs.rb +26 -19
- data/lib/xeno/reaper.rb +13 -17
- data/lib/xeno/schedules.rb +8 -9
- data/lib/xeno/session_state.rb +8 -9
- data/lib/xeno/standalone/local_secret.rb +7 -6
- data/lib/xeno/standalone/model_refresh.rb +6 -6
- data/lib/xeno/standalone/puma.rb +9 -9
- data/lib/xeno/standalone.rb +22 -21
- data/lib/xeno/tool.rb +28 -25
- data/lib/xeno/turn_runner.rb +74 -96
- data/lib/xeno/version.rb +3 -1
- data/lib/xeno.rb +16 -14
- metadata +4 -9
- data/db/migrate/20260804000001_create_xeno_llm_tables.rb +0 -70
- data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +0 -8
- data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +0 -8
- data/db/migrate/20260805000003_create_xeno_dedups.rb +0 -14
- data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +0 -9
- data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +0 -8
data/app/models/xeno/event.rb
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# The append-only session stream — also the audit log. `index` is the
|
|
3
|
-
#
|
|
4
|
-
# re-emitted events by (session, index).
|
|
4
|
+
# The append-only session stream — also the audit log. `index` is the per-session cursor;
|
|
5
|
+
# consumers dedupe re-emitted events by (session, index).
|
|
5
6
|
class Event < ApplicationRecord
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
# The fixed vocabulary (docs/runtime.md § Events). Hooks may subscribe
|
|
9
|
-
# to any of these or "*".
|
|
7
|
+
# The fixed vocabulary (docs/runtime.md § Events). Hooks may subscribe to any of these or "*".
|
|
10
8
|
TYPES = %w[
|
|
11
9
|
session.started turn.started message.received
|
|
12
10
|
step.started step.completed step.failed
|
|
@@ -18,16 +16,16 @@ module Xeno
|
|
|
18
16
|
session.waiting session.completed session.failed
|
|
19
17
|
].freeze
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
belongs_to :session, class_name: "Xeno::Session"
|
|
20
|
+
|
|
21
|
+
# Hooks fire only after the row is durably committed (and therefore visible to other processes)
|
|
22
|
+
# — never inside the emitting transaction.
|
|
23
23
|
after_create_commit { Hooks.dispatch(self) }
|
|
24
24
|
|
|
25
|
-
# Appends with a race-safe per-session index: concurrent writers collide
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# aborts the whole transaction — the retry would raise
|
|
30
|
-
# PG::InFailedSqlTransaction instead of recovering.
|
|
25
|
+
# Appends with a race-safe per-session index: concurrent writers collide on the unique index and
|
|
26
|
+
# retry with the next slot. The INSERT runs in its own savepoint because emit is routinely
|
|
27
|
+
# called inside enclosing transactions, and on Postgres a failed INSERT would otherwise abort
|
|
28
|
+
# the whole transaction.
|
|
31
29
|
def self.append!(session, event_type, data = {})
|
|
32
30
|
attempts = 0
|
|
33
31
|
begin
|
|
@@ -42,11 +40,9 @@ module Xeno
|
|
|
42
40
|
)
|
|
43
41
|
end
|
|
44
42
|
rescue ActiveRecord::RecordNotUnique
|
|
45
|
-
# Every collision means a competing append
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
# burst can cost more than a handful of rounds — back off with jitter
|
|
49
|
-
# instead of giving up early.
|
|
43
|
+
# Every collision means a competing append succeeded, so retries are bounded by actual
|
|
44
|
+
# contention. SQLite's file lock serializes writers; Postgres runs them concurrently, so a
|
|
45
|
+
# burst can cost several rounds — back off with jitter instead of giving up early.
|
|
50
46
|
attempts += 1
|
|
51
47
|
raise if attempts >= 50
|
|
52
48
|
|
data/app/models/xeno/message.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# Messages that arrived while a turn was running. Drained into the next
|
|
3
|
-
#
|
|
4
|
+
# Messages that arrived while a turn was running. Drained into the next turn when the session
|
|
5
|
+
# parks or the turn completes.
|
|
4
6
|
class PendingMessage < ApplicationRecord
|
|
5
7
|
belongs_to :session, class_name: "Xeno::Session"
|
|
6
8
|
end
|
data/app/models/xeno/session.rb
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# = Sessions
|
|
5
|
+
#
|
|
6
|
+
# A session is one durable conversation with the agent. It can live for days or weeks and
|
|
7
|
+
# survive deploys, crashes, and restarts, because everything it is lives in rows: the transcript
|
|
8
|
+
# chat, the turn ledger, and the append-only event stream.
|
|
9
|
+
#
|
|
10
|
+
# A session is identified two ways. The id is the stable handle for inspection and streaming.
|
|
11
|
+
# The continuation_token is the resume handle a channel owns — this Slack thread, this HTTP
|
|
12
|
+
# client — unique among active sessions and released when the session ends, so the same token
|
|
13
|
+
# can start fresh.
|
|
6
14
|
class Session < ApplicationRecord
|
|
7
|
-
|
|
15
|
+
enum :status, %w[running waiting completed failed].index_with(&:itself), validate: true
|
|
8
16
|
|
|
9
17
|
belongs_to :chat, class_name: "Xeno::Chat"
|
|
10
18
|
has_many :turns, class_name: "Xeno::Turn", dependent: :destroy
|
|
11
19
|
has_many :events, class_name: "Xeno::Event", dependent: :destroy
|
|
12
20
|
has_many :pending_messages, class_name: "Xeno::PendingMessage", dependent: :destroy
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
#
|
|
17
|
-
# per the resolved agent definition, stamps channel + principal, and
|
|
18
|
-
# stages turn 1. The caller enqueues the returned turn's job after the
|
|
19
|
-
# transaction commits (Session.start! does both).
|
|
22
|
+
# Opens a session for a first user message: builds the transcript chat per the resolved agent
|
|
23
|
+
# definition, stamps channel + principal, and stages turn 1. The caller enqueues the returned
|
|
24
|
+
# turn's job after the transaction commits (Session.start! does both).
|
|
20
25
|
def self.open!(message:, channel: nil, principal: nil, continuation_token: nil, definition: Xeno.definition)
|
|
21
26
|
transaction do
|
|
22
27
|
chat = Chat.new
|
|
@@ -46,27 +51,22 @@ module Xeno
|
|
|
46
51
|
session
|
|
47
52
|
end
|
|
48
53
|
|
|
49
|
-
# Stages the next turn for one or more messages: persists each user
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
# appends the turn row. Runs in the caller's process so TurnJob replays
|
|
53
|
-
# never double-add the user message.
|
|
54
|
+
# Stages the next turn for one or more messages: persists each user message, refreshes
|
|
55
|
+
# instructions, and appends the turn row. Runs in the caller's process so TurnJob replays never
|
|
56
|
+
# double-add the user message.
|
|
54
57
|
#
|
|
55
|
-
# defer_transcript: the turn row carries the messages
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
# (drained turns): appending user rows behind a parked turn's unanswered
|
|
59
|
-
# tool calls would wedge every later generate (providers demand tool
|
|
60
|
-
# results immediately after the assistant's tool_calls message).
|
|
58
|
+
# defer_transcript: the turn row carries the messages and the runner writes them at claim time.
|
|
59
|
+
# Required whenever an earlier turn may still be mid-flight — appending user rows behind
|
|
60
|
+
# unanswered tool calls would wedge every later generate.
|
|
61
61
|
def stage_turn!(messages, definition: Xeno.definition, emit_received: true, defer_transcript: false)
|
|
62
62
|
contents = Array(messages)
|
|
63
63
|
transaction do
|
|
64
64
|
unless defer_transcript
|
|
65
|
-
#
|
|
66
|
-
#
|
|
65
|
+
# with_instructions triggers the first to_llm build, so a fresh chat record must get its
|
|
66
|
+
# runtime options back first. nil clears: an agent whose instructions were removed sheds
|
|
67
|
+
# the stale system row.
|
|
67
68
|
chat.apply_runtime_options!(definition.config.model_options)
|
|
68
|
-
|
|
69
|
-
chat.with_instructions(resolved) if resolved
|
|
69
|
+
chat.with_instructions(definition.instructions_for(session: self))
|
|
70
70
|
contents.each { |content| chat.add_message(role: :user, content: content) }
|
|
71
71
|
end
|
|
72
72
|
turn = Turn.append!(self, user_message: { contents: contents }, transcript_deferred: defer_transcript)
|
|
@@ -78,19 +78,16 @@ module Xeno
|
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
# The channel entry point for follow-up messages. While a turn is active
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
# (the message becomes a visible staged turn instead of sitting invisible
|
|
85
|
-
# until someone resolves the input — mvp-design's delivery semantics).
|
|
86
|
-
# An idle session stages and enqueues a turn right away.
|
|
81
|
+
# The channel entry point for follow-up messages. While a turn is active the message queues in
|
|
82
|
+
# pending_messages and folds into the next turn; a parked session drains immediately, so the
|
|
83
|
+
# message becomes a visible staged turn. An idle session stages and enqueues right away.
|
|
87
84
|
def receive_message!(content)
|
|
88
|
-
if turns.
|
|
85
|
+
if turns.active.exists?
|
|
89
86
|
transaction do
|
|
90
87
|
pending_messages.create!(payload: { "content" => content })
|
|
91
88
|
emit("message.received", { content: content, queued: true })
|
|
92
89
|
end
|
|
93
|
-
drain_pending_messages! if reload.
|
|
90
|
+
drain_pending_messages! if reload.waiting?
|
|
94
91
|
nil
|
|
95
92
|
else
|
|
96
93
|
turn = stage_turn!(content)
|
|
@@ -99,13 +96,10 @@ module Xeno
|
|
|
99
96
|
end
|
|
100
97
|
end
|
|
101
98
|
|
|
102
|
-
# Folds every queued message into
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
# user rows are written only when the runner claims it — turn order and
|
|
107
|
-
# transcript order can never diverge. Row locks make a racing drain
|
|
108
|
-
# (park vs. incoming message) fold each message exactly once.
|
|
99
|
+
# Folds every queued message into one next turn, called when a turn parks or ends and on
|
|
100
|
+
# messages to a parked session. Always defers the transcript: the drained turn may sit behind
|
|
101
|
+
# unanswered tool calls, so its user rows are written only at claim — turn order and transcript
|
|
102
|
+
# order can never diverge. Row locks make a racing drain fold each message exactly once.
|
|
109
103
|
def drain_pending_messages!(definition: Xeno.definition)
|
|
110
104
|
turn = nil
|
|
111
105
|
transaction do
|
|
@@ -119,19 +113,15 @@ module Xeno
|
|
|
119
113
|
turn
|
|
120
114
|
end
|
|
121
115
|
|
|
122
|
-
# Steering (opt-in per message): stop what the agent is doing and make
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
# immediately; a running turn cancels cooperatively (the steer message
|
|
127
|
-
# queues and the runner's cancel path folds it into the next turn at the
|
|
128
|
-
# next step boundary). If the cancel loses the race with a completing
|
|
129
|
-
# turn, steering degrades to a normal follow-up — never an error.
|
|
116
|
+
# Steering (opt-in per message): stop what the agent is doing and make this message the next
|
|
117
|
+
# turn. A parked or pending turn settles its dangling tool calls and cancels immediately; a
|
|
118
|
+
# running turn cancels cooperatively at the next step boundary. If the cancel loses the race
|
|
119
|
+
# with a completing turn, steering degrades to a normal follow-up — never an error.
|
|
130
120
|
def steer!(content, definition: Xeno.definition)
|
|
131
|
-
turn = turns.
|
|
121
|
+
turn = turns.active.order(:sequence).first
|
|
132
122
|
|
|
133
123
|
case turn&.status
|
|
134
|
-
when nil # idle: steer
|
|
124
|
+
when nil # idle: steer degrades to a plain follow-up message
|
|
135
125
|
staged = stage_turn!(content, definition: definition)
|
|
136
126
|
staged.enqueue!
|
|
137
127
|
staged
|
|
@@ -147,7 +137,7 @@ module Xeno
|
|
|
147
137
|
settle_unanswered_tool_calls!(turn, reason: "steered by user")
|
|
148
138
|
turn.update!(status: "cancelled")
|
|
149
139
|
emit("turn.cancelled", { turn_id: turn.id, steer: true })
|
|
150
|
-
update!(status: "running") if
|
|
140
|
+
update!(status: "running") if waiting?
|
|
151
141
|
end
|
|
152
142
|
staged = stage_turn!(content, definition: definition)
|
|
153
143
|
staged.enqueue!
|
|
@@ -155,11 +145,9 @@ module Xeno
|
|
|
155
145
|
end
|
|
156
146
|
end
|
|
157
147
|
|
|
158
|
-
# Stages a compaction turn (summarize-and-replace, executed by the
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
# queued-behind-the-active-turn semantics — and never appends a
|
|
162
|
-
# synthetic user message.
|
|
148
|
+
# Stages a compaction turn (summarize-and-replace, executed by the runner under a normal claim).
|
|
149
|
+
# It queues behind any active or parked turn via the ordinary session-ordering machinery and
|
|
150
|
+
# never appends a synthetic user message.
|
|
163
151
|
def stage_compaction_turn!(reason:, used: nil, limit: nil)
|
|
164
152
|
transaction do
|
|
165
153
|
request = { "reason" => reason, "used" => used, "limit" => limit }.compact
|
|
@@ -174,13 +162,10 @@ module Xeno
|
|
|
174
162
|
Event.append!(self, event_type, data)
|
|
175
163
|
end
|
|
176
164
|
|
|
177
|
-
# Called before a turn dies with tool calls still unanswered
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
# the session is bricked. A completed action (e.g. an answered question
|
|
182
|
-
# whose resume never ran) injects its recorded output; everything else
|
|
183
|
-
# gets a denial, recorded on the action row as the audit trail.
|
|
165
|
+
# Called before a turn dies with tool calls still unanswered: every dangling call gets a tool
|
|
166
|
+
# result in the transcript. Without this the unanswered rows make every later generate a
|
|
167
|
+
# provider 400 and the session is bricked. A completed action injects its recorded output;
|
|
168
|
+
# everything else gets a denial, recorded on the action row.
|
|
184
169
|
def settle_unanswered_tool_calls!(turn, reason:)
|
|
185
170
|
chat_record = Chat.find(chat_id)
|
|
186
171
|
llm = chat_record.to_llm
|
|
@@ -212,11 +197,11 @@ module Xeno
|
|
|
212
197
|
end
|
|
213
198
|
|
|
214
199
|
def active?
|
|
215
|
-
|
|
200
|
+
running? || waiting?
|
|
216
201
|
end
|
|
217
202
|
|
|
218
|
-
# Terminal states release the continuation token (a finished session must
|
|
219
|
-
#
|
|
203
|
+
# Terminal states release the continuation token (a finished session must not squat a channel
|
|
204
|
+
# thread's token); a copy stays in metadata for audit.
|
|
220
205
|
def finish!(new_status)
|
|
221
206
|
raise ArgumentError, "not a terminal status: #{new_status}" unless %w[completed failed].include?(new_status)
|
|
222
207
|
|
data/app/models/xeno/turn.rb
CHANGED
|
@@ -1,29 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# One user message and all work until the agent responds
|
|
3
|
-
#
|
|
4
|
+
# One user message and all work until the agent responds, run as one ActiveJob. Correctness lives
|
|
5
|
+
# in these rows, not in the queue:
|
|
4
6
|
#
|
|
5
|
-
# 1. Atomic claim (CAS on claim_token)
|
|
6
|
-
# 2. Heartbeat
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# 4. Max attempts — poison turns become turn.failed, not infinite retries.
|
|
7
|
+
# 1. Atomic claim (CAS on claim_token): one worker owns the turn.
|
|
8
|
+
# 2. Heartbeat: a stale heartbeat lets the next attempt reclaim;
|
|
9
|
+
# checkpoint replay makes takeover safe.
|
|
10
|
+
# 3. Fencing token: every write is conditioned on claim_token = mine,
|
|
11
|
+
# so a reaped zombie affects zero rows.
|
|
12
|
+
# 4. Max attempts: poison turns fail instead of retrying forever.
|
|
12
13
|
class Turn < ApplicationRecord
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
enum :status, %w[pending running waiting completed failed cancelled].index_with(&:itself), validate: true
|
|
15
|
+
enum :kind, %w[message compaction].index_with(&:itself), prefix: true, validate: true
|
|
15
16
|
|
|
16
17
|
belongs_to :session, class_name: "Xeno::Session"
|
|
17
18
|
has_many :actions, class_name: "Xeno::Action", dependent: :destroy
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
validates :kind, inclusion: { in: KINDS }
|
|
20
|
+
scope :active, -> { where(status: %w[pending running waiting]) }
|
|
21
21
|
|
|
22
|
-
# Appends the next turn
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# aborts the whole transaction — the retry would raise
|
|
26
|
-
# PG::InFailedSqlTransaction instead of recovering.
|
|
22
|
+
# Appends the next turn with a race-safely assigned sequence. The INSERT runs in its own
|
|
23
|
+
# savepoint because stage_turn! calls this inside a transaction, and on Postgres a failed INSERT
|
|
24
|
+
# would otherwise abort the whole transaction.
|
|
27
25
|
def self.append!(session, user_message:, transcript_deferred: false, kind: "message")
|
|
28
26
|
attempts = 0
|
|
29
27
|
begin
|
|
@@ -43,15 +41,13 @@ module Xeno
|
|
|
43
41
|
TurnJob.perform_later(id)
|
|
44
42
|
end
|
|
45
43
|
|
|
46
|
-
# The atomic claim. Returns the fencing token
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
# Poison turns are failed here instead of retrying forever.
|
|
44
|
+
# The atomic claim. Returns the fencing token, or nil when another worker owns the turn or it
|
|
45
|
+
# isn't claimable. Claimable: pending, or running with a stale heartbeat. Poison turns fail here
|
|
46
|
+
# instead of retrying forever.
|
|
50
47
|
#
|
|
51
|
-
# `attempts` counts
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
# and never against the poison ladder (H2).
|
|
48
|
+
# `attempts` counts failures, not claims: a stale-running reclaim is crash evidence and counts;
|
|
49
|
+
# a transient error counts in release_for_retry!; an approval or question resume counts in
|
|
50
|
+
# `resumes`, never against the poison ladder.
|
|
55
51
|
def claim!
|
|
56
52
|
current = self.class.find(id)
|
|
57
53
|
|
|
@@ -60,8 +56,8 @@ module Xeno
|
|
|
60
56
|
.where.not(status: %w[completed failed cancelled])
|
|
61
57
|
.update_all(status: "failed", error: { "message" => "max attempts (#{current.attempts}) exhausted" })
|
|
62
58
|
if poisoned == 1
|
|
63
|
-
# A poisoned TURN leaves the SESSION running (idle, can take the
|
|
64
|
-
#
|
|
59
|
+
# A poisoned TURN leaves the SESSION running (idle, can take the next message); settle any
|
|
60
|
+
# dangling tool calls so it stays usable.
|
|
65
61
|
session.settle_unanswered_tool_calls!(self, reason: "turn failed (max attempts exhausted)")
|
|
66
62
|
session.emit("turn.failed", { turn_id: id, error: "max attempts exhausted" })
|
|
67
63
|
end
|
|
@@ -72,7 +68,7 @@ module Xeno
|
|
|
72
68
|
earlier_active = self.class
|
|
73
69
|
.where(session_id: session_id)
|
|
74
70
|
.where("sequence < ?", sequence)
|
|
75
|
-
.
|
|
71
|
+
.active
|
|
76
72
|
|
|
77
73
|
claimed = self.class
|
|
78
74
|
.where(id: id, claim_token: current.claim_token)
|
|
@@ -88,9 +84,8 @@ module Xeno
|
|
|
88
84
|
claimed == 1 ? current.claim_token + 1 : nil
|
|
89
85
|
end
|
|
90
86
|
|
|
91
|
-
# Transient failure: record the error, count the failure against the
|
|
92
|
-
#
|
|
93
|
-
# retry can claim immediately.
|
|
87
|
+
# Transient failure: record the error, count the failure against the poison ladder, and hand the
|
|
88
|
+
# claim back (status pending) so the queue retry can claim immediately.
|
|
94
89
|
def release_for_retry!(token, error)
|
|
95
90
|
current = self.class.find(id)
|
|
96
91
|
fenced_update!(token,
|
|
@@ -99,8 +94,8 @@ module Xeno
|
|
|
99
94
|
error: { "class" => error.class.name, "message" => error.message })
|
|
100
95
|
end
|
|
101
96
|
|
|
102
|
-
# Fenced write: touches the heartbeat iff we still own the claim.
|
|
103
|
-
#
|
|
97
|
+
# Fenced write: touches the heartbeat iff we still own the claim. Raises Xeno::Fenced when a
|
|
98
|
+
# zombie discovers it was reaped.
|
|
104
99
|
def heartbeat!(token)
|
|
105
100
|
fenced_update!(token, heartbeat_at: Time.current)
|
|
106
101
|
end
|
data/config/routes.rb
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
|
|
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]
|
|
1
|
+
class CreateRubyLlmTables < ActiveRecord::Migration[8.1]
|
|
12
2
|
def change
|
|
3
|
+
create_models
|
|
4
|
+
create_tool_calls
|
|
5
|
+
create_usage_entries
|
|
6
|
+
create_batches
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def create_models
|
|
12
|
+
return if table_exists?(:ruby_llm_models)
|
|
13
|
+
|
|
13
14
|
create_table :ruby_llm_models do |t|
|
|
14
15
|
t.string :model_id, null: false
|
|
15
16
|
t.string :name, null: false
|
|
@@ -29,6 +30,10 @@ class MoveTranscriptSupportTablesToRubyLlm < ActiveRecord::Migration[8.1]
|
|
|
29
30
|
t.index :provider
|
|
30
31
|
t.index :family
|
|
31
32
|
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def create_tool_calls
|
|
36
|
+
return if table_exists?(:ruby_llm_tool_calls)
|
|
32
37
|
|
|
33
38
|
create_table :ruby_llm_tool_calls do |t|
|
|
34
39
|
t.references :message, polymorphic: true, null: false, index: false
|
|
@@ -36,6 +41,7 @@ class MoveTranscriptSupportTablesToRubyLlm < ActiveRecord::Migration[8.1]
|
|
|
36
41
|
t.string :tool_call_id, null: false
|
|
37
42
|
t.string :name, null: false
|
|
38
43
|
t.text :thought_signature
|
|
44
|
+
t.string :approval
|
|
39
45
|
t.json :arguments, default: {}
|
|
40
46
|
t.timestamps
|
|
41
47
|
|
|
@@ -44,6 +50,10 @@ class MoveTranscriptSupportTablesToRubyLlm < ActiveRecord::Migration[8.1]
|
|
|
44
50
|
t.index :tool_call_id, unique: true
|
|
45
51
|
t.index :name
|
|
46
52
|
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def create_usage_entries
|
|
56
|
+
return if table_exists?(:ruby_llm_usage_entries)
|
|
47
57
|
|
|
48
58
|
create_table :ruby_llm_usage_entries do |t|
|
|
49
59
|
t.references :chat, polymorphic: true, null: false, index: false
|
|
@@ -71,6 +81,10 @@ class MoveTranscriptSupportTablesToRubyLlm < ActiveRecord::Migration[8.1]
|
|
|
71
81
|
t.check_constraint "operation IN ('chat', 'embedding', 'moderation', 'image', 'speech', 'transcription')"
|
|
72
82
|
t.check_constraint "status IN ('pending', 'succeeded', 'failed', 'cancelled')"
|
|
73
83
|
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def create_batches
|
|
87
|
+
return if table_exists?(:ruby_llm_batches)
|
|
74
88
|
|
|
75
89
|
create_table :ruby_llm_batches do |t|
|
|
76
90
|
t.string :provider_batch_id, null: false
|
|
@@ -86,48 +100,5 @@ class MoveTranscriptSupportTablesToRubyLlm < ActiveRecord::Migration[8.1]
|
|
|
86
100
|
t.index [ :provider, :provider_batch_id ], unique: true
|
|
87
101
|
t.index :status
|
|
88
102
|
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
103
|
end
|
|
133
104
|
end
|
|
@@ -1,18 +1,36 @@
|
|
|
1
|
-
|
|
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]
|
|
1
|
+
class CreateXenoTables < ActiveRecord::Migration[8.1]
|
|
7
2
|
def change
|
|
3
|
+
create_table :xeno_chats do |t|
|
|
4
|
+
t.boolean :cancelled, null: false, default: false
|
|
5
|
+
t.references :ruby_llm_model, foreign_key: { to_table: :ruby_llm_models }
|
|
6
|
+
t.timestamps
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
create_table :xeno_messages do |t|
|
|
10
|
+
t.string :role, null: false
|
|
11
|
+
t.text :content
|
|
12
|
+
t.boolean :cache_until_here, null: false, default: false
|
|
13
|
+
t.text :thinking_text
|
|
14
|
+
t.text :thinking_signature
|
|
15
|
+
t.json :citations
|
|
16
|
+
t.json :server_tool_calls
|
|
17
|
+
t.json :raw_content
|
|
18
|
+
t.string :finish_reason
|
|
19
|
+
t.references :chat, null: false, foreign_key: { to_table: :xeno_chats }
|
|
20
|
+
t.timestamps
|
|
21
|
+
|
|
22
|
+
t.index :role
|
|
23
|
+
end
|
|
24
|
+
|
|
8
25
|
create_table :xeno_sessions do |t|
|
|
9
26
|
t.string :agent, null: false
|
|
10
|
-
t.string :status, null: false, default: "running"
|
|
27
|
+
t.string :status, null: false, default: "running"
|
|
11
28
|
t.string :channel
|
|
12
29
|
t.string :continuation_token
|
|
13
30
|
t.string :title
|
|
14
31
|
t.json :principal
|
|
15
32
|
t.json :metadata, default: {}
|
|
33
|
+
t.json :state, null: false, default: {}
|
|
16
34
|
t.references :chat, null: false, foreign_key: { to_table: :xeno_chats }
|
|
17
35
|
t.timestamps
|
|
18
36
|
|
|
@@ -23,11 +41,14 @@ class CreateXenoOrchestrationTables < ActiveRecord::Migration[8.1]
|
|
|
23
41
|
create_table :xeno_turns do |t|
|
|
24
42
|
t.references :session, null: false, foreign_key: { to_table: :xeno_sessions }
|
|
25
43
|
t.integer :sequence, null: false
|
|
26
|
-
t.string :status, null: false, default: "pending"
|
|
44
|
+
t.string :status, null: false, default: "pending"
|
|
45
|
+
t.string :kind, null: false, default: "message"
|
|
27
46
|
t.integer :claim_token, null: false, default: 0
|
|
28
47
|
t.datetime :heartbeat_at
|
|
29
48
|
t.integer :attempts, null: false, default: 0
|
|
49
|
+
t.integer :resumes, null: false, default: 0
|
|
30
50
|
t.integer :steps_count, null: false, default: 0
|
|
51
|
+
t.boolean :transcript_deferred, null: false, default: false
|
|
31
52
|
t.json :user_message
|
|
32
53
|
t.json :error
|
|
33
54
|
t.timestamps
|
|
@@ -38,14 +59,14 @@ class CreateXenoOrchestrationTables < ActiveRecord::Migration[8.1]
|
|
|
38
59
|
|
|
39
60
|
create_table :xeno_actions do |t|
|
|
40
61
|
t.references :turn, null: false, foreign_key: { to_table: :xeno_turns }
|
|
41
|
-
t.string :tool_call_id, null: false
|
|
62
|
+
t.string :tool_call_id, null: false
|
|
42
63
|
t.string :tool_name, null: false
|
|
43
|
-
t.string :kind, null: false, default: "tool"
|
|
44
|
-
t.string :status, null: false, default: "pending"
|
|
64
|
+
t.string :kind, null: false, default: "tool"
|
|
65
|
+
t.string :status, null: false, default: "pending"
|
|
45
66
|
t.json :input
|
|
46
67
|
t.json :output
|
|
47
|
-
t.datetime :resolved_at
|
|
48
|
-
t.json :resolved_by
|
|
68
|
+
t.datetime :resolved_at
|
|
69
|
+
t.json :resolved_by
|
|
49
70
|
t.timestamps
|
|
50
71
|
|
|
51
72
|
t.index [ :turn_id, :tool_call_id ], unique: true
|
|
@@ -53,7 +74,7 @@ class CreateXenoOrchestrationTables < ActiveRecord::Migration[8.1]
|
|
|
53
74
|
|
|
54
75
|
create_table :xeno_events do |t|
|
|
55
76
|
t.references :session, null: false, foreign_key: { to_table: :xeno_sessions }
|
|
56
|
-
t.integer :index, null: false
|
|
77
|
+
t.integer :index, null: false
|
|
57
78
|
t.string :event_type, null: false
|
|
58
79
|
t.json :data
|
|
59
80
|
t.datetime :created_at, null: false
|
|
@@ -66,5 +87,14 @@ class CreateXenoOrchestrationTables < ActiveRecord::Migration[8.1]
|
|
|
66
87
|
t.json :payload, null: false
|
|
67
88
|
t.timestamps
|
|
68
89
|
end
|
|
90
|
+
|
|
91
|
+
create_table :xeno_dedups do |t|
|
|
92
|
+
t.string :scope, null: false
|
|
93
|
+
t.string :key, null: false
|
|
94
|
+
t.json :metadata
|
|
95
|
+
t.datetime :created_at, null: false
|
|
96
|
+
|
|
97
|
+
t.index [ :scope, :key ], unique: true
|
|
98
|
+
end
|
|
69
99
|
end
|
|
70
100
|
end
|