xeno 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -2
  3. data/README.md +3 -1
  4. data/Rakefile +2 -0
  5. data/app/controllers/xeno/api_controller.rb +15 -18
  6. data/app/controllers/xeno/application_controller.rb +2 -0
  7. data/app/controllers/xeno/dev_controller.rb +5 -4
  8. data/app/controllers/xeno/dev_ui_controller.rb +5 -4
  9. data/app/controllers/xeno/health_controller.rb +3 -1
  10. data/app/controllers/xeno/sessions_controller.rb +22 -24
  11. data/app/controllers/xeno/slack_controller.rb +9 -10
  12. data/app/controllers/xeno/streams_controller.rb +18 -22
  13. data/app/helpers/xeno/application_helper.rb +2 -0
  14. data/app/jobs/xeno/application_job.rb +2 -0
  15. data/app/jobs/xeno/reaper_job.rb +5 -4
  16. data/app/jobs/xeno/schedule_job.rb +10 -13
  17. data/app/jobs/xeno/slack_event_job.rb +7 -7
  18. data/app/jobs/xeno/turn_job.rb +5 -4
  19. data/app/mailers/xeno/application_mailer.rb +2 -0
  20. data/app/models/xeno/action.rb +7 -12
  21. data/app/models/xeno/application_record.rb +2 -0
  22. data/app/models/xeno/chat.rb +9 -11
  23. data/app/models/xeno/dedup.rb +7 -7
  24. data/app/models/xeno/event.rb +16 -20
  25. data/app/models/xeno/message.rb +2 -0
  26. data/app/models/xeno/pending_message.rb +4 -2
  27. data/app/models/xeno/session.rb +52 -67
  28. data/app/models/xeno/turn.rb +29 -34
  29. data/config/routes.rb +2 -0
  30. data/db/migrate/{20260806000001_move_transcript_support_tables_to_ruby_llm.rb → 20260808000001_create_ruby_llm_tables.rb} +25 -54
  31. data/db/migrate/{20260804000002_create_xeno_orchestration_tables.rb → 20260808000002_create_xeno_tables.rb} +44 -14
  32. data/docs/configuration.md +56 -0
  33. data/docs/runtime.md +4 -4
  34. data/exe/xeno +1 -1
  35. data/lib/generators/xeno/install/install_generator.rb +3 -2
  36. data/lib/generators/xeno/install/templates/agent.rb +1 -2
  37. data/lib/generators/xeno/install/templates/initializer.rb +6 -9
  38. data/lib/generators/xeno/tool/tool_generator.rb +4 -2
  39. data/lib/tasks/xeno_tasks.rake +2 -0
  40. data/lib/xeno/agent_config.rb +9 -8
  41. data/lib/xeno/agent_definition.rb +25 -28
  42. data/lib/xeno/approval_context.rb +2 -0
  43. data/lib/xeno/arguments.rb +8 -9
  44. data/lib/xeno/ask_question.rb +5 -5
  45. data/lib/xeno/channels/slack.rb +25 -29
  46. data/lib/xeno/channels.rb +10 -9
  47. data/lib/xeno/compaction.rb +20 -33
  48. data/lib/xeno/configuration.rb +35 -44
  49. data/lib/xeno/engine.rb +8 -7
  50. data/lib/xeno/errors.rb +11 -10
  51. data/lib/xeno/hooks.rb +7 -10
  52. data/lib/xeno/info.rb +3 -2
  53. data/lib/xeno/inputs.rb +26 -19
  54. data/lib/xeno/reaper.rb +13 -17
  55. data/lib/xeno/schedules.rb +8 -9
  56. data/lib/xeno/session_state.rb +8 -9
  57. data/lib/xeno/standalone/local_secret.rb +7 -6
  58. data/lib/xeno/standalone/model_refresh.rb +6 -6
  59. data/lib/xeno/standalone/puma.rb +9 -9
  60. data/lib/xeno/standalone.rb +22 -21
  61. data/lib/xeno/tool.rb +28 -25
  62. data/lib/xeno/turn_runner.rb +74 -96
  63. data/lib/xeno/version.rb +3 -1
  64. data/lib/xeno.rb +16 -14
  65. metadata +4 -9
  66. data/db/migrate/20260804000001_create_xeno_llm_tables.rb +0 -70
  67. data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +0 -8
  68. data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +0 -8
  69. data/db/migrate/20260805000003_create_xeno_dedups.rb +0 -14
  70. data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +0 -9
  71. data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +0 -8
data/lib/xeno/tool.rb CHANGED
@@ -1,7 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # Base class for agent tools. Anyone who knows RubyLLM already knows how
3
- # to write these; xeno adds discovery and (later) the approval macro and
4
- # session context.
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. The path supplies the
19
- # name: Zeitwerk guarantees agent/tools/get_weather.rb
20
- # defines Xeno::Tools::GetWeather, so the demodulized class name and
21
- # the file basename are the same thing. No suffix-stripping: a
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. Anything irreversible or externally
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
- # arguments, principal); truthy means approval is required.
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
- # RubyLLM derives the wire name from the full class name, which would
48
- # leak the namespace (xeno/tools/get_weather). Use the path-derived name.
49
- def name
50
- self.class.tool_name
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
- # `call`. nil when the tool is exercised outside a session (unit tests
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
- # never cross sessions, and are cleared by reset. Working state, not
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
@@ -1,19 +1,18 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # Drives one turn to completion (or to a park). This is the durable core:
3
- # RubyLLM's decomposed v2 loop (`generate` + self-executed tools +
4
- # `add_message` injection), checkpointed in the database at every move.
5
- # We never call `run_tools` (not idempotent) or `complete` (unbounded).
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
- # Crash-safety invariants:
8
- # - The user message and instructions were persisted when the turn was
9
- # staged; the runner only advances the transcript.
10
- # - `generate` persists the assistant message via the acts_as callback path.
11
- # - A tool execution and its transcript injection commit in ONE transaction
12
- # (the action row is the checkpoint) — recorded calls are never re-run.
13
- # - Every write is fenced on the claim token; zombies affect zero rows.
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
- # by stage_turn! in this same process) would ignore the runtime attrs
43
- # prepare_chat sets (protocol, assume_model_exists).
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
- # in a schedule run deterministically fails the turn.
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
- # every process re-applies them from the definition before the first
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 persisted cancellation flag set before this turn claimed is stale —
112
- # it targeted work that no longer exists (the previous turn ended, or a
113
- # cancel raced a completion) and would otherwise poison this turn's
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
- # Spike #2, hazard 1: a process killed mid-generate leaves a blank
122
- # assistant row that reads as a completed turn. Trailing blank assistant
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
- # claimed (a mid-flight earlier transcript must not be appended to).
141
- # Staged here, exactly once: the appends and the flag flip commit
142
- # together, and the flip is fenced — a crash replays the whole thing,
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
- resolved = definition.instructions_for(session: session)
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 is allowed to run, and collects everything that needs a human.
170
- # Ungated calls in a mixed batch still execute before the park (their
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: either an
266
- # answered question (Inputs writes the answer to the action only) or a
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 NOT bubble into the queue's retry ladder: the
281
- # action row wouldn't exist as a checkpoint, so every retry would
282
- # re-execute the tool (side effects!), and after retry_on exhausted the
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
- # injected on replay — the tool is never re-executed.
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 visibly alive while this thread is buried in a model
344
- # call or a tool body — the calls that can outlast turn_stale_after and
345
- # would otherwise get reclaimed mid-flight by claim! or the reaper. A
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
- # heartbeat is already stale — a beater would just hammer the DB.
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
- # sink is a failable nicety and never touches the turn's fate.
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
- # that lands, the completion post would be a duplicate.
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, before EACH model call, from the per-attempt usage ledger
423
- # (ruby_llm_usage_entries) — the provider's own counts, summed across
424
- # every attempt of this session's chat, retries included. The call that
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
- # durable truth is already in the transcript and events. Skipped when a
458
- # streamer already delivered this reply (post-then-edit).
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, and
467
- # chain the next pending turn's job (later turns can't claim while an
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 is waiting, and the
478
- # job ENDS — no process waits. Input resolution enqueues the resume.
479
- # Messages that queued behind this turn become visible NOW as a staged
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
- # the retry can claim immediately, and let the error propagate to the
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
- # The graceful-shutdown checkpoint (AJ Continuation's stopping? probe,
517
- # adopted): between steps, a stopping worker hands the turn back instead
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 (status
534
- # pending, ladder untouched) and the turn re-enqueues itself for the
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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- VERSION = "0.0.1"
4
+ VERSION = "0.0.2"
3
5
  end
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
- # (standalone mode, tests).
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
- # at turn-stage time with the session context:
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
- # return appended. The block runs on EVERY turn stage — keep it fast.
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
- # events (any type from the vocabulary, or "*"):
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
- # re-emit). Raising never breaks the runtime.
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
- # Rails reloading resets it (see engine's to_prepare).
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
- # "1"/"true" (a truthy-check would let "0" enable them — S5).
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.1
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/20260804000001_create_xeno_llm_tables.rb
84
- - db/migrate/20260804000002_create_xeno_orchestration_tables.rb
85
- - db/migrate/20260805000001_add_resumes_to_xeno_turns.rb
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