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/lib/xeno/tool.rb
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
4
|
+
# = Tools
|
|
5
|
+
#
|
|
6
|
+
# A tool is something the agent can do besides talk: look up an order, charge a card, file a
|
|
7
|
+
# ticket. Each tool is a class under agent/tools/, and the model decides when to call it based
|
|
8
|
+
# on the description and parameters you declare:
|
|
5
9
|
#
|
|
6
10
|
# # agent/tools/get_weather.rb
|
|
7
11
|
# class Xeno::Tools::GetWeather < Xeno::Tool
|
|
@@ -13,51 +17,50 @@ module Xeno
|
|
|
13
17
|
# end
|
|
14
18
|
# end
|
|
15
19
|
#
|
|
20
|
+
# The file name is the tool's name on the wire — get_weather.rb is callable as get_weather — and
|
|
21
|
+
# whatever execute returns becomes the tool result the model reads next.
|
|
22
|
+
#
|
|
23
|
+
# Beyond a plain RubyLLM::Tool, a xeno tool runs inside a durable session: it can keep working
|
|
24
|
+
# state in #state, see the #session it runs in, and declare an ::approval policy so a human
|
|
25
|
+
# signs off before anything irreversible happens.
|
|
16
26
|
class Tool < RubyLLM::Tool
|
|
17
27
|
class << self
|
|
18
|
-
# The runtime name the model calls this tool by
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# `charge_card_tool.rb` must be callable as `charge_card_tool`, or the
|
|
23
|
-
# runtime's slug-keyed lookup misses and the approval gate is skipped.
|
|
28
|
+
# The runtime name the model calls this tool by, taken from the path:
|
|
29
|
+
# agent/tools/get_weather.rb defines Xeno::Tools::GetWeather, and the demodulized class name
|
|
30
|
+
# matches the file basename. No suffix stripping: a charge_card_tool.rb must stay callable as
|
|
31
|
+
# charge_card_tool or the slug-keyed approval lookup misses.
|
|
24
32
|
def tool_name
|
|
25
33
|
name.demodulize.underscore
|
|
26
34
|
end
|
|
27
35
|
|
|
28
|
-
# The human-in-the-loop gate.
|
|
29
|
-
# visible should be gated — approvals are the guardrail in an
|
|
30
|
-
# in-app-tools trust model.
|
|
36
|
+
# The human-in-the-loop gate. Gate anything irreversible or externally visible.
|
|
31
37
|
#
|
|
32
38
|
# approval :never # default: runs without asking
|
|
33
39
|
# approval :once # asks the first time in a session, then remembered
|
|
34
40
|
# approval :always # asks on every call
|
|
35
41
|
# approval ->(ctx) { ctx.principal&.dig("role") != "admin" }
|
|
36
42
|
#
|
|
37
|
-
# The lambda receives an ApprovalContext (session, turn, tool_name,
|
|
38
|
-
#
|
|
43
|
+
# The lambda receives an ApprovalContext (session, turn, tool_name, arguments, principal);
|
|
44
|
+
# truthy means approval is required.
|
|
39
45
|
def approval(policy = nil)
|
|
40
46
|
@approval = policy unless policy.nil?
|
|
41
47
|
return @approval if defined?(@approval) && @approval
|
|
42
48
|
|
|
43
49
|
superclass.respond_to?(:approval) ? superclass.approval : :never
|
|
44
50
|
end
|
|
45
|
-
end
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
def approval_declared? # :nodoc:
|
|
53
|
+
(defined?(@approval) && !@approval.nil?) ||
|
|
54
|
+
(superclass.respond_to?(:approval_declared?) && superclass.approval_declared?)
|
|
55
|
+
end
|
|
51
56
|
end
|
|
52
57
|
|
|
53
|
-
# The session this call is running in — set by the runner before
|
|
54
|
-
#
|
|
55
|
-
# calling `Tool.new.call` directly).
|
|
58
|
+
# The session this call is running in — set by the runner before `call`. nil when the tool is
|
|
59
|
+
# exercised outside a session (unit tests calling `Tool.new.call` directly).
|
|
56
60
|
attr_accessor :session
|
|
57
61
|
|
|
58
|
-
# The session-scoped KV store: JSON-typed values that survive restarts,
|
|
59
|
-
#
|
|
60
|
-
# long-term memory.
|
|
62
|
+
# The session-scoped KV store: JSON-typed values that survive restarts, never cross sessions,
|
|
63
|
+
# and are cleared by reset. Working state, not long-term memory.
|
|
61
64
|
#
|
|
62
65
|
# def execute(city:)
|
|
63
66
|
# searches = state.get("searches").to_i + 1
|
data/lib/xeno/turn_runner.rb
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# Drives one turn to completion
|
|
3
|
-
# RubyLLM's decomposed
|
|
4
|
-
# `
|
|
5
|
-
#
|
|
4
|
+
# Drives one turn to completion or to a park, checkpointing every move in the database. Uses
|
|
5
|
+
# RubyLLM's decomposed loop — `generate`, self-executed tools, `add_message` injection — and never
|
|
6
|
+
# `run_tools` (no approval gate, no per-call checkpoint) or `complete` (a turn must bound its
|
|
7
|
+
# model calls).
|
|
6
8
|
#
|
|
7
|
-
#
|
|
8
|
-
# -
|
|
9
|
-
#
|
|
10
|
-
# -
|
|
11
|
-
# -
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
# - Trailing blank assistant rows (the mid-generate crash window) are
|
|
15
|
-
# removed before deciding anything (spike #2, hazard 1).
|
|
16
|
-
class TurnRunner
|
|
9
|
+
# Invariants:
|
|
10
|
+
# - A tool execution and its transcript injection commit in one
|
|
11
|
+
# transaction; recorded calls are never re-run.
|
|
12
|
+
# - Every write is fenced on the claim token; a zombie affects zero rows.
|
|
13
|
+
# - Trailing blank assistant rows (mid-generate crash artifacts) are
|
|
14
|
+
# removed before anything else.
|
|
15
|
+
class TurnRunner # :nodoc:
|
|
17
16
|
include Compaction
|
|
18
17
|
|
|
19
18
|
attr_reader :turn, :session, :definition, :token
|
|
@@ -38,9 +37,9 @@ module Xeno
|
|
|
38
37
|
private
|
|
39
38
|
|
|
40
39
|
def run_claimed
|
|
41
|
-
# A pristine record instance: a memoized to_llm built elsewhere (e.g.
|
|
42
|
-
#
|
|
43
|
-
#
|
|
40
|
+
# A pristine record instance: a memoized to_llm built elsewhere (e.g. by stage_turn! in this
|
|
41
|
+
# same process) would ignore the runtime attrs prepare_chat sets (protocol,
|
|
42
|
+
# assume_model_exists).
|
|
44
43
|
@chat = Chat.find(session.chat_id)
|
|
45
44
|
clear_stale_cancellation
|
|
46
45
|
|
|
@@ -72,8 +71,8 @@ module Xeno
|
|
|
72
71
|
interrupt_turn
|
|
73
72
|
:interrupted
|
|
74
73
|
rescue Xeno::Parked => parked
|
|
75
|
-
# Schedules have no human to wait for: a gated tool (or ask_question)
|
|
76
|
-
#
|
|
74
|
+
# Schedules have no human to wait for: a gated tool (or ask_question) in a schedule run
|
|
75
|
+
# deterministically fails the turn.
|
|
77
76
|
if session.channel == "schedule"
|
|
78
77
|
fail_schedule_park
|
|
79
78
|
:failed
|
|
@@ -100,27 +99,22 @@ module Xeno
|
|
|
100
99
|
raise
|
|
101
100
|
end
|
|
102
101
|
|
|
103
|
-
# assume_model_exists and protocol are not persisted on the chat record —
|
|
104
|
-
#
|
|
105
|
-
# to_llm build (which with_tools triggers).
|
|
102
|
+
# assume_model_exists and protocol are not persisted on the chat record — every process
|
|
103
|
+
# re-applies them from the definition before the first to_llm build (which with_tools triggers).
|
|
106
104
|
def prepare_chat
|
|
107
105
|
@chat.apply_runtime_options!(definition.config.model_options)
|
|
108
106
|
@chat.with_tools(Xeno::AskQuestion, *definition.tool_classes)
|
|
109
107
|
end
|
|
110
108
|
|
|
111
|
-
# A
|
|
112
|
-
# it
|
|
113
|
-
#
|
|
114
|
-
# first generate. Cancellation is documented as cooperative/best-effort,
|
|
115
|
-
# so the tiny window where a cancel lands between our claim and this
|
|
116
|
-
# clear is an accepted trade-off.
|
|
109
|
+
# A cancellation flag set before this turn claimed targeted work that no longer exists; clear
|
|
110
|
+
# it. Cancellation is cooperative, so a cancel landing between the claim and this clear is an
|
|
111
|
+
# accepted loss.
|
|
117
112
|
def clear_stale_cancellation
|
|
118
113
|
Chat.where(id: @chat.id, cancelled: true).update_all(cancelled: false)
|
|
119
114
|
end
|
|
120
115
|
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
# rows (no content, no tool calls) are crash artifacts — delete them.
|
|
116
|
+
# A kill mid-generate leaves a blank assistant row. Trailing blank assistant rows are crash
|
|
117
|
+
# artifacts; delete them.
|
|
124
118
|
def remove_crash_artifacts
|
|
125
119
|
loop do
|
|
126
120
|
last = @chat.messages_association.order(:id).last
|
|
@@ -136,19 +130,16 @@ module Xeno
|
|
|
136
130
|
@chat.reload
|
|
137
131
|
end
|
|
138
132
|
|
|
139
|
-
# A drained turn carries its user messages in the turn row until it is
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
# a zombie stages nothing. Instructions refresh here too, so a deploy
|
|
144
|
-
# between drain and claim behaves like any other new turn.
|
|
133
|
+
# A drained turn carries its user messages in the turn row until it is claimed; appending
|
|
134
|
+
# earlier would corrupt a mid-flight transcript. The appends and the flag flip commit together
|
|
135
|
+
# and the flip is fenced, so a crash replays and a zombie stages nothing. Instructions refresh
|
|
136
|
+
# here too.
|
|
145
137
|
def stage_deferred_transcript!
|
|
146
138
|
return unless turn.transcript_deferred?
|
|
147
139
|
|
|
148
140
|
contents = Array(turn.user_message&.fetch("contents", nil))
|
|
149
141
|
ActiveRecord::Base.transaction do
|
|
150
|
-
|
|
151
|
-
@chat.with_instructions(resolved) if resolved
|
|
142
|
+
@chat.with_instructions(definition.instructions_for(session: session))
|
|
152
143
|
contents.each { |content| @chat.add_message(role: :user, content: content) }
|
|
153
144
|
turn.fenced_update!(token, transcript_deferred: false)
|
|
154
145
|
end
|
|
@@ -165,10 +156,9 @@ module Xeno
|
|
|
165
156
|
last_assistant.tool_calls.values.reject { |call| answered.include?(call.id) }
|
|
166
157
|
end
|
|
167
158
|
|
|
168
|
-
# Resolves the batch: replays recorded work, injects denials, executes
|
|
169
|
-
# what
|
|
170
|
-
#
|
|
171
|
-
# results are recorded, so the resume replays them for free).
|
|
159
|
+
# Resolves the batch: replays recorded work, injects denials, executes what may run, and
|
|
160
|
+
# collects what needs a human. Ungated calls in a mixed batch execute before the park; the
|
|
161
|
+
# resume replays their recorded results.
|
|
172
162
|
def resolve_tool_calls(calls)
|
|
173
163
|
blocking = []
|
|
174
164
|
|
|
@@ -203,6 +193,13 @@ module Xeno
|
|
|
203
193
|
tool_class = definition.tools[call.name]
|
|
204
194
|
return false unless tool_class.respond_to?(:approval)
|
|
205
195
|
|
|
196
|
+
# A tool declaring RubyLLM's requires_approval without a xeno policy gets the gate. An
|
|
197
|
+
# explicit xeno policy, even :never, wins.
|
|
198
|
+
if tool_class.respond_to?(:approval_declared?) && !tool_class.approval_declared? &&
|
|
199
|
+
tool_class.respond_to?(:requires_approval?) && tool_class.requires_approval?
|
|
200
|
+
return true
|
|
201
|
+
end
|
|
202
|
+
|
|
206
203
|
policy = tool_class.approval
|
|
207
204
|
case policy
|
|
208
205
|
when :never, nil then false
|
|
@@ -262,9 +259,8 @@ module Xeno
|
|
|
262
259
|
@chat.to_llm.messages.any? { |m| m.role == :tool && m.tool_call_id == call.id }
|
|
263
260
|
end
|
|
264
261
|
|
|
265
|
-
# A completed action whose result is not yet in the transcript
|
|
266
|
-
#
|
|
267
|
-
# self-heal after replay. Inject the recorded output — never re-execute.
|
|
262
|
+
# A completed action whose result is not yet in the transcript — an answered question, or a
|
|
263
|
+
# self-heal after replay. Inject the recorded output; never re-execute.
|
|
268
264
|
def replay_completed_action(action, call)
|
|
269
265
|
return false unless action.completed?
|
|
270
266
|
|
|
@@ -277,12 +273,9 @@ module Xeno
|
|
|
277
273
|
true
|
|
278
274
|
end
|
|
279
275
|
|
|
280
|
-
# A raising tool must
|
|
281
|
-
#
|
|
282
|
-
#
|
|
283
|
-
# turn would wedge. The exception becomes an error tool result the
|
|
284
|
-
# model can recover from; the failed action row replays like any other
|
|
285
|
-
# checkpoint.
|
|
276
|
+
# A raising tool must not reach the queue's retry ladder — without the action-row checkpoint
|
|
277
|
+
# every retry would re-execute it. The exception becomes an error tool result the model can
|
|
278
|
+
# recover from, and the failed action replays like any other checkpoint.
|
|
286
279
|
def execute_action(action, call)
|
|
287
280
|
tool_class = definition.tools[call.name]
|
|
288
281
|
failure = nil
|
|
@@ -297,7 +290,7 @@ module Xeno
|
|
|
297
290
|
result = ActiveSupport::Notifications.instrument("xeno.action", turn_id: turn.id, tool: call.name) do
|
|
298
291
|
tool = tool_class.new
|
|
299
292
|
tool.session = session
|
|
300
|
-
with_heartbeat { tool.call(arguments) }
|
|
293
|
+
with_heartbeat { tool.call(arguments, tool_call: call) }
|
|
301
294
|
end
|
|
302
295
|
stringify_result(result)
|
|
303
296
|
rescue StandardError => e
|
|
@@ -322,8 +315,8 @@ module Xeno
|
|
|
322
315
|
})
|
|
323
316
|
end
|
|
324
317
|
|
|
325
|
-
# A failed action is a checkpoint too: the recorded error result is
|
|
326
|
-
#
|
|
318
|
+
# A failed action is a checkpoint too: the recorded error result is injected on replay — the
|
|
319
|
+
# tool is never re-executed.
|
|
327
320
|
def replay_failed_action(action, call)
|
|
328
321
|
return false unless action.status == "failed"
|
|
329
322
|
|
|
@@ -340,16 +333,13 @@ module Xeno
|
|
|
340
333
|
@chat.add_message(role: :tool, content: content, tool_call_id: call.id)
|
|
341
334
|
end
|
|
342
335
|
|
|
343
|
-
# Keeps the claim
|
|
344
|
-
#
|
|
345
|
-
#
|
|
346
|
-
# sibling thread (own DB connection) beats heartbeat_at until the block
|
|
347
|
-
# returns; a fenced beat means we were reclaimed anyway, so it stops and
|
|
348
|
-
# lets the next fenced write in this thread raise.
|
|
336
|
+
# Keeps the claim alive through model calls and tool bodies, the calls that can outlast
|
|
337
|
+
# turn_stale_after. A sibling thread with its own DB connection beats heartbeat_at until the
|
|
338
|
+
# block returns; a fenced beat means the turn was reclaimed, so it stops.
|
|
349
339
|
def with_heartbeat
|
|
350
340
|
interval = Xeno.config.heartbeat_interval.to_f
|
|
351
|
-
# A non-positive interval (turn_stale_after 0 in tests) means every
|
|
352
|
-
#
|
|
341
|
+
# A non-positive interval (turn_stale_after 0 in tests) means every heartbeat is already stale
|
|
342
|
+
# — a beater would just hammer the DB.
|
|
353
343
|
return yield unless interval.positive?
|
|
354
344
|
|
|
355
345
|
stop = Thread::Queue.new
|
|
@@ -386,8 +376,8 @@ module Xeno
|
|
|
386
376
|
step_index = turn.record_step!(token)
|
|
387
377
|
session.emit("step.started", { turn_id: turn.id, step: step_index })
|
|
388
378
|
|
|
389
|
-
# A channel that streams (Slack post-then-edit) gets the deltas; the
|
|
390
|
-
#
|
|
379
|
+
# A channel that streams (Slack post-then-edit) gets the deltas; the sink is a failable nicety
|
|
380
|
+
# and never touches the turn's fate.
|
|
391
381
|
streamer = Channels.streamer_for(session)
|
|
392
382
|
response = ActiveSupport::Notifications.instrument("xeno.step", turn_id: turn.id, step: step_index) do
|
|
393
383
|
with_heartbeat do
|
|
@@ -411,20 +401,17 @@ module Xeno
|
|
|
411
401
|
})
|
|
412
402
|
else
|
|
413
403
|
emit_completion_events(response)
|
|
414
|
-
# The streamer writes the durable final text into its message; when
|
|
415
|
-
#
|
|
404
|
+
# The streamer writes the durable final text into its message; when that lands, the
|
|
405
|
+
# completion post would be a duplicate.
|
|
416
406
|
@stream_delivered = true if streamer&.finish(response.content)
|
|
417
407
|
end
|
|
418
408
|
|
|
419
409
|
response
|
|
420
410
|
end
|
|
421
411
|
|
|
422
|
-
# Budgets
|
|
423
|
-
#
|
|
424
|
-
#
|
|
425
|
-
# crosses a cap is allowed to finish (usage is only known afterwards);
|
|
426
|
-
# the NEXT call trips. Checked here (not at claim) so a turn that needs
|
|
427
|
-
# no model call — pure replay — still settles cleanly.
|
|
412
|
+
# Budgets are checked before each model call, from the per-attempt usage ledger (retries
|
|
413
|
+
# included). The call that crosses a cap finishes — usage is only known afterwards — and the
|
|
414
|
+
# next call trips. Checked here rather than at claim so a pure-replay turn still settles.
|
|
428
415
|
def enforce_budgets!
|
|
429
416
|
check_budget_axis!("input", :input_tokens, Xeno.config.max_input_tokens_per_session)
|
|
430
417
|
check_budget_axis!("output", :output_tokens, Xeno.config.max_output_tokens_per_session)
|
|
@@ -453,9 +440,9 @@ module Xeno
|
|
|
453
440
|
advance_session
|
|
454
441
|
end
|
|
455
442
|
|
|
456
|
-
# Channel delivery (Slack thread reply, etc.) — a failable nicety; the
|
|
457
|
-
#
|
|
458
|
-
#
|
|
443
|
+
# Channel delivery (Slack thread reply, etc.) — a failable nicety; the durable truth is already
|
|
444
|
+
# in the transcript and events. Skipped when a streamer already delivered this reply
|
|
445
|
+
# (post-then-edit).
|
|
459
446
|
def deliver_final_answer
|
|
460
447
|
return if @stream_delivered
|
|
461
448
|
|
|
@@ -463,10 +450,8 @@ module Xeno
|
|
|
463
450
|
Channels.deliver_completion(session, final.content) if final&.content.present?
|
|
464
451
|
end
|
|
465
452
|
|
|
466
|
-
# After a terminal turn: fold queued messages into the next turn
|
|
467
|
-
#
|
|
468
|
-
# earlier one is non-terminal, so completion is what wakes them). A
|
|
469
|
-
# session retired mid-turn (reset) stops advancing.
|
|
453
|
+
# After a terminal turn: fold queued messages into the next turn and chain the next pending
|
|
454
|
+
# turn's job — completion is what wakes later turns. A session retired mid-turn stops advancing.
|
|
470
455
|
def advance_session
|
|
471
456
|
return unless session.reload.active?
|
|
472
457
|
|
|
@@ -474,10 +459,9 @@ module Xeno
|
|
|
474
459
|
session.turns.where(status: "pending").order(:sequence).first&.enqueue!
|
|
475
460
|
end
|
|
476
461
|
|
|
477
|
-
# Parking: the pending actions are recorded, the turn
|
|
478
|
-
#
|
|
479
|
-
#
|
|
480
|
-
# (deferred) next turn — mvp-design's drain-on-park delivery semantics.
|
|
462
|
+
# Parking: the pending actions are recorded, the turn waits, and the job ends — no process
|
|
463
|
+
# waits. Input resolution enqueues the resume; queued messages become a staged (deferred) next
|
|
464
|
+
# turn immediately.
|
|
481
465
|
def park_turn(actions = [])
|
|
482
466
|
turn.fenced_update!(token, status: "waiting")
|
|
483
467
|
session.update!(status: "waiting")
|
|
@@ -504,19 +488,16 @@ module Xeno
|
|
|
504
488
|
advance_session
|
|
505
489
|
end
|
|
506
490
|
|
|
507
|
-
# Transient failure: count it, hand the claim back (status pending) so
|
|
508
|
-
#
|
|
509
|
-
# queue's retry machinery.
|
|
491
|
+
# Transient failure: count it, hand the claim back (status pending) so the retry can claim
|
|
492
|
+
# immediately, and let the error propagate to the queue's retry machinery.
|
|
510
493
|
def release_for_retry(error)
|
|
511
494
|
turn.release_for_retry!(token, error)
|
|
512
495
|
rescue Xeno::Fenced
|
|
513
496
|
nil
|
|
514
497
|
end
|
|
515
498
|
|
|
516
|
-
#
|
|
517
|
-
#
|
|
518
|
-
# of dying mid-step and waiting out stale-heartbeat reclaim. Every move
|
|
519
|
-
# so far is checkpointed, so the next worker fast-forwards for free.
|
|
499
|
+
# Between steps, a stopping worker hands the turn back instead of dying mid-step and waiting out
|
|
500
|
+
# stale-heartbeat reclaim. Every move so far is checkpointed, so the next worker fast-forwards.
|
|
520
501
|
def checkpoint_shutdown!
|
|
521
502
|
raise Xeno::Interrupted, "worker is shutting down" if stopping?
|
|
522
503
|
end
|
|
@@ -530,11 +511,8 @@ module Xeno
|
|
|
530
511
|
false # an adapter without the probe never interrupts
|
|
531
512
|
end
|
|
532
513
|
|
|
533
|
-
# Not a failure and not crash evidence: the claim is released
|
|
534
|
-
#
|
|
535
|
-
# next worker. The re-enqueue is at-least-once like everything else —
|
|
536
|
-
# if it is lost with the worker, the reaper's stale-pending sweep is
|
|
537
|
-
# the backstop.
|
|
514
|
+
# Not a failure and not crash evidence: the claim is released with the ladder untouched and the
|
|
515
|
+
# turn re-enqueues itself. A lost re-enqueue is caught by the reaper's stale-pending sweep.
|
|
538
516
|
def interrupt_turn
|
|
539
517
|
turn.fenced_update!(token, status: "pending", heartbeat_at: Time.current)
|
|
540
518
|
turn.enqueue!
|
data/lib/xeno/version.rb
CHANGED
data/lib/xeno.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "ruby_llm"
|
|
2
4
|
|
|
3
5
|
require "xeno/version"
|
|
@@ -21,8 +23,8 @@ require "xeno/engine"
|
|
|
21
23
|
|
|
22
24
|
module Xeno
|
|
23
25
|
class << self
|
|
24
|
-
# The app's agent directory. Defaults to <Rails.root>/agent; overridable
|
|
25
|
-
#
|
|
26
|
+
# The app's agent directory. Defaults to <Rails.root>/agent; overridable (standalone mode,
|
|
27
|
+
# tests).
|
|
26
28
|
attr_writer :agent_root
|
|
27
29
|
|
|
28
30
|
def agent_root
|
|
@@ -50,15 +52,15 @@ module Xeno
|
|
|
50
52
|
@captured_agent_config
|
|
51
53
|
end
|
|
52
54
|
|
|
53
|
-
# The agent/instructions.rb entry point — dynamic instructions resolved
|
|
54
|
-
#
|
|
55
|
+
# The agent/instructions.rb entry point — dynamic instructions resolved at turn-stage time with
|
|
56
|
+
# the session context:
|
|
55
57
|
#
|
|
56
58
|
# Xeno.instructions do |context|
|
|
57
59
|
# "You are helping #{context.principal&.dig("name") || "a guest"}."
|
|
58
60
|
# end
|
|
59
61
|
#
|
|
60
|
-
# Composes with the static instructions.md: static first, the block's
|
|
61
|
-
#
|
|
62
|
+
# Composes with the static instructions.md: static first, the block's return appended. The block
|
|
63
|
+
# runs on EVERY turn stage — keep it fast.
|
|
62
64
|
def instructions(&block)
|
|
63
65
|
@captured_instructions = block
|
|
64
66
|
end
|
|
@@ -72,15 +74,15 @@ module Xeno
|
|
|
72
74
|
@captured_instructions
|
|
73
75
|
end
|
|
74
76
|
|
|
75
|
-
# The agent/hooks/*.rb entry point — observe-only handlers for stream
|
|
76
|
-
#
|
|
77
|
+
# The agent/hooks/*.rb entry point — observe-only handlers for stream events (any type from the
|
|
78
|
+
# vocabulary, or "*"):
|
|
77
79
|
#
|
|
78
80
|
# Xeno.hook "turn.completed" do |event|
|
|
79
81
|
# Metrics.increment("agent.turns")
|
|
80
82
|
# end
|
|
81
83
|
#
|
|
82
|
-
# Handlers fire after the event commits, at-least-once (replays
|
|
83
|
-
#
|
|
84
|
+
# Handlers fire after the event commits, at-least-once (replays re-emit). Raising never breaks
|
|
85
|
+
# the runtime.
|
|
84
86
|
def hook(event_type, &block)
|
|
85
87
|
raise ArgumentError, "Xeno.hook requires a block" unless block
|
|
86
88
|
|
|
@@ -94,8 +96,8 @@ module Xeno
|
|
|
94
96
|
@captured_hooks
|
|
95
97
|
end
|
|
96
98
|
|
|
97
|
-
# The resolved agent definition, discovered on first use and cached.
|
|
98
|
-
#
|
|
99
|
+
# The resolved agent definition, discovered on first use and cached. Rails reloading resets it
|
|
100
|
+
# (see engine's to_prepare).
|
|
99
101
|
def definition
|
|
100
102
|
@definition ||= AgentDefinition.load(agent_root, name: default_agent_name)
|
|
101
103
|
end
|
|
@@ -108,8 +110,8 @@ module Xeno
|
|
|
108
110
|
Rails.application.class.module_parent_name.underscore
|
|
109
111
|
end
|
|
110
112
|
|
|
111
|
-
# Dev routes exist in development, or when XENO_DEV_ROUTES is exactly
|
|
112
|
-
#
|
|
113
|
+
# Dev routes exist in development, or when XENO_DEV_ROUTES is exactly "1"/"true" (a truthy-check
|
|
114
|
+
# would let "0" enable them).
|
|
113
115
|
def dev_routes_enabled?
|
|
114
116
|
Rails.env.development? || %w[1 true].include?(ENV["XENO_DEV_ROUTES"].to_s)
|
|
115
117
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: xeno
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nicolas Erlichman
|
|
@@ -80,14 +80,9 @@ files:
|
|
|
80
80
|
- app/views/xeno/dev_ui/index.html.erb
|
|
81
81
|
- app/views/xeno/dev_ui/show.html.erb
|
|
82
82
|
- config/routes.rb
|
|
83
|
-
- db/migrate/
|
|
84
|
-
- db/migrate/
|
|
85
|
-
-
|
|
86
|
-
- db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb
|
|
87
|
-
- db/migrate/20260805000003_create_xeno_dedups.rb
|
|
88
|
-
- db/migrate/20260805000004_add_kind_to_xeno_turns.rb
|
|
89
|
-
- db/migrate/20260805000005_add_state_to_xeno_sessions.rb
|
|
90
|
-
- db/migrate/20260806000001_move_transcript_support_tables_to_ruby_llm.rb
|
|
83
|
+
- db/migrate/20260808000001_create_ruby_llm_tables.rb
|
|
84
|
+
- db/migrate/20260808000002_create_xeno_tables.rb
|
|
85
|
+
- docs/configuration.md
|
|
91
86
|
- docs/runtime.md
|
|
92
87
|
- exe/xeno
|
|
93
88
|
- lib/generators/xeno/install/install_generator.rb
|
|
@@ -1,70 +0,0 @@
|
|
|
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
|
|
@@ -1,8 +0,0 @@
|
|
|
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
|
|
@@ -1,8 +0,0 @@
|
|
|
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
|
|
@@ -1,14 +0,0 @@
|
|
|
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
|
|
@@ -1,9 +0,0 @@
|
|
|
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
|