xeno 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +38 -0
  3. data/LICENSE +21 -0
  4. data/README.md +211 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/xeno/application.css +15 -0
  7. data/app/controllers/xeno/api_controller.rb +68 -0
  8. data/app/controllers/xeno/application_controller.rb +4 -0
  9. data/app/controllers/xeno/dev_controller.rb +24 -0
  10. data/app/controllers/xeno/dev_ui_controller.rb +71 -0
  11. data/app/controllers/xeno/health_controller.rb +10 -0
  12. data/app/controllers/xeno/sessions_controller.rb +131 -0
  13. data/app/controllers/xeno/slack_controller.rb +48 -0
  14. data/app/controllers/xeno/streams_controller.rb +122 -0
  15. data/app/helpers/xeno/application_helper.rb +4 -0
  16. data/app/jobs/xeno/application_job.rb +4 -0
  17. data/app/jobs/xeno/reaper_job.rb +12 -0
  18. data/app/jobs/xeno/schedule_job.rb +56 -0
  19. data/app/jobs/xeno/slack_event_job.rb +20 -0
  20. data/app/jobs/xeno/turn_job.rb +16 -0
  21. data/app/mailers/xeno/application_mailer.rb +6 -0
  22. data/app/models/xeno/action.rb +26 -0
  23. data/app/models/xeno/application_record.rb +5 -0
  24. data/app/models/xeno/chat.rb +22 -0
  25. data/app/models/xeno/dedup.rb +24 -0
  26. data/app/models/xeno/event.rb +63 -0
  27. data/app/models/xeno/message.rb +5 -0
  28. data/app/models/xeno/pending_message.rb +7 -0
  29. data/app/models/xeno/session.rb +231 -0
  30. data/app/models/xeno/turn.rb +125 -0
  31. data/app/views/layouts/xeno/application.html.erb +18 -0
  32. data/app/views/xeno/dev_ui/_styles.html.erb +24 -0
  33. data/app/views/xeno/dev_ui/index.html.erb +28 -0
  34. data/app/views/xeno/dev_ui/show.html.erb +115 -0
  35. data/config/routes.rb +25 -0
  36. data/db/migrate/20260804000001_create_xeno_llm_tables.rb +70 -0
  37. data/db/migrate/20260804000002_create_xeno_orchestration_tables.rb +70 -0
  38. data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +8 -0
  39. data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +8 -0
  40. data/db/migrate/20260805000003_create_xeno_dedups.rb +14 -0
  41. data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +9 -0
  42. data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +8 -0
  43. data/db/migrate/20260806000001_move_transcript_support_tables_to_ruby_llm.rb +133 -0
  44. data/docs/runtime.md +275 -0
  45. data/exe/xeno +133 -0
  46. data/lib/generators/xeno/install/install_generator.rb +51 -0
  47. data/lib/generators/xeno/install/templates/agent.rb +4 -0
  48. data/lib/generators/xeno/install/templates/initializer.rb +20 -0
  49. data/lib/generators/xeno/install/templates/instructions.md +6 -0
  50. data/lib/generators/xeno/tool/templates/tool.rb.tt +16 -0
  51. data/lib/generators/xeno/tool/tool_generator.rb +13 -0
  52. data/lib/tasks/xeno_tasks.rake +24 -0
  53. data/lib/xeno/agent_config.rb +66 -0
  54. data/lib/xeno/agent_definition.rb +286 -0
  55. data/lib/xeno/approval_context.rb +4 -0
  56. data/lib/xeno/arguments.rb +62 -0
  57. data/lib/xeno/ask_question.rb +18 -0
  58. data/lib/xeno/channels/slack.rb +311 -0
  59. data/lib/xeno/channels.rb +68 -0
  60. data/lib/xeno/compaction.rb +165 -0
  61. data/lib/xeno/configuration.rb +118 -0
  62. data/lib/xeno/engine.rb +29 -0
  63. data/lib/xeno/errors.rb +40 -0
  64. data/lib/xeno/hooks.rb +37 -0
  65. data/lib/xeno/info.rb +75 -0
  66. data/lib/xeno/inputs.rb +78 -0
  67. data/lib/xeno/reaper.rb +52 -0
  68. data/lib/xeno/schedules.rb +49 -0
  69. data/lib/xeno/session_state.rb +57 -0
  70. data/lib/xeno/standalone/local_secret.rb +26 -0
  71. data/lib/xeno/standalone/model_refresh.rb +26 -0
  72. data/lib/xeno/standalone/puma.rb +17 -0
  73. data/lib/xeno/standalone.rb +136 -0
  74. data/lib/xeno/tool.rb +73 -0
  75. data/lib/xeno/turn_runner.rb +545 -0
  76. data/lib/xeno/version.rb +3 -0
  77. data/lib/xeno.rb +117 -0
  78. metadata +151 -0
@@ -0,0 +1,545 @@
1
+ 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).
6
+ #
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
17
+ include Compaction
18
+
19
+ attr_reader :turn, :session, :definition, :token
20
+
21
+ def initialize(turn, definition: Xeno.definition)
22
+ @turn = turn
23
+ @session = turn.session
24
+ @definition = definition
25
+ end
26
+
27
+ def run
28
+ @token = turn.claim!
29
+ return :not_claimed unless token
30
+
31
+ ActiveSupport::Notifications.instrument("xeno.turn", turn_id: turn.id, session_id: session.id) do
32
+ run_claimed
33
+ end
34
+ rescue Xeno::Fenced
35
+ :fenced
36
+ end
37
+
38
+ private
39
+
40
+ 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).
44
+ @chat = Chat.find(session.chat_id)
45
+ clear_stale_cancellation
46
+
47
+ if turn.kind == "compaction"
48
+ run_compaction
49
+ return :completed
50
+ end
51
+
52
+ prepare_chat
53
+ remove_crash_artifacts
54
+ stage_deferred_transcript!
55
+
56
+ loop do
57
+ checkpoint_shutdown!
58
+
59
+ calls = unanswered_tool_calls
60
+ if calls.any?
61
+ resolve_tool_calls(calls)
62
+ elsif @chat.complete?
63
+ break
64
+ else
65
+ generate_step
66
+ end
67
+ end
68
+
69
+ complete_turn
70
+ :completed
71
+ rescue Xeno::Interrupted
72
+ interrupt_turn
73
+ :interrupted
74
+ 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.
77
+ if session.channel == "schedule"
78
+ fail_schedule_park
79
+ :failed
80
+ else
81
+ park_turn(parked.actions)
82
+ :parked
83
+ end
84
+ rescue RubyLLM::CancelledError
85
+ cancel_turn
86
+ :cancelled
87
+ rescue Xeno::BudgetExceeded => e
88
+ session.emit("budget.exceeded", {
89
+ turn_id: turn.id, axis: e.axis, used: e.used, limit: e.limit
90
+ })
91
+ fail_turn(e)
92
+ :failed
93
+ rescue Xeno::MaxStepsExceeded => e
94
+ fail_turn(e)
95
+ :failed
96
+ rescue Xeno::Fenced
97
+ raise
98
+ rescue StandardError => e
99
+ release_for_retry(e)
100
+ raise
101
+ end
102
+
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).
106
+ def prepare_chat
107
+ @chat.apply_runtime_options!(definition.config.model_options)
108
+ @chat.with_tools(Xeno::AskQuestion, *definition.tool_classes)
109
+ end
110
+
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.
117
+ def clear_stale_cancellation
118
+ Chat.where(id: @chat.id, cancelled: true).update_all(cancelled: false)
119
+ end
120
+
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.
124
+ def remove_crash_artifacts
125
+ loop do
126
+ last = @chat.messages_association.order(:id).last
127
+ break unless last&.role == "assistant" && last.content.blank? && !last.tool_call?
128
+
129
+ last.destroy!
130
+ @chat.messages_association.reset
131
+ end
132
+ sync_llm_messages
133
+ end
134
+
135
+ def sync_llm_messages
136
+ @chat.reload
137
+ end
138
+
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.
145
+ def stage_deferred_transcript!
146
+ return unless turn.transcript_deferred?
147
+
148
+ contents = Array(turn.user_message&.fetch("contents", nil))
149
+ ActiveRecord::Base.transaction do
150
+ resolved = definition.instructions_for(session: session)
151
+ @chat.with_instructions(resolved) if resolved
152
+ contents.each { |content| @chat.add_message(role: :user, content: content) }
153
+ turn.fenced_update!(token, transcript_deferred: false)
154
+ end
155
+ sync_llm_messages
156
+ end
157
+
158
+ # Unanswered tool calls from the last assistant message, in request order.
159
+ def unanswered_tool_calls
160
+ llm = @chat.to_llm
161
+ last_assistant = llm.messages.reverse.find { |m| m.role == :assistant }
162
+ return [] unless last_assistant&.tool_call?
163
+
164
+ answered = llm.messages.select { |m| m.role == :tool }.map(&:tool_call_id).compact.to_set
165
+ last_assistant.tool_calls.values.reject { |call| answered.include?(call.id) }
166
+ end
167
+
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).
172
+ def resolve_tool_calls(calls)
173
+ blocking = []
174
+
175
+ calls.each do |call|
176
+ kind = call.name == "ask_question" ? "question" : "tool"
177
+ action = Action.record!(turn, call, kind: kind)
178
+
179
+ next if replay_completed_action(action, call)
180
+ next if replay_denied_action(action, call)
181
+ next if replay_failed_action(action, call)
182
+
183
+ case action.status
184
+ when "pending_approval"
185
+ blocking << action # parked before; still unresolved
186
+ when "approved"
187
+ execute_action(action, call)
188
+ else # pending
189
+ if kind == "question"
190
+ blocking << request_input(action, call)
191
+ elsif approval_required?(call)
192
+ blocking << request_input(action, call)
193
+ else
194
+ execute_action(action, call)
195
+ end
196
+ end
197
+ end
198
+
199
+ raise Xeno::Parked.new(actions: blocking) if blocking.any?
200
+ end
201
+
202
+ def approval_required?(call)
203
+ tool_class = definition.tools[call.name]
204
+ return false unless tool_class.respond_to?(:approval)
205
+
206
+ policy = tool_class.approval
207
+ case policy
208
+ when :never, nil then false
209
+ when :always then true
210
+ when :once then !previously_approved?(call.name)
211
+ when Proc then !!policy.call(approval_context(call))
212
+ else
213
+ raise Xeno::Error, "unknown approval policy #{policy.inspect} on #{tool_class}"
214
+ end
215
+ end
216
+
217
+ # :once — an approval granted earlier in this session covers later calls.
218
+ def previously_approved?(tool_name)
219
+ Action.joins(:turn)
220
+ .where(xeno_turns: { session_id: session.id })
221
+ .where(tool_name: tool_name)
222
+ .where.not(resolved_at: nil)
223
+ .where(status: %w[approved completed])
224
+ .exists?
225
+ end
226
+
227
+ def approval_context(call)
228
+ ApprovalContext.new(
229
+ session: session, turn: turn,
230
+ tool_name: call.name, arguments: call.arguments,
231
+ principal: session.principal
232
+ )
233
+ end
234
+
235
+ def request_input(action, call)
236
+ action.update!(status: "pending_approval")
237
+ session.emit("input.requested", {
238
+ turn_id: turn.id,
239
+ action_id: action.id,
240
+ call_id: call.id,
241
+ kind: action.kind,
242
+ tool: call.name,
243
+ arguments: call.arguments
244
+ })
245
+ action
246
+ end
247
+
248
+ def replay_denied_action(action, call)
249
+ return false unless action.status == "denied"
250
+
251
+ unless answered?(call)
252
+ content = action.output&.fetch("content", nil) || JSON.generate({ denied: true })
253
+ inject_result(call, content)
254
+ session.emit("action.result", {
255
+ turn_id: turn.id, call_id: call.id, tool: call.name, status: "denied"
256
+ })
257
+ end
258
+ true
259
+ end
260
+
261
+ def answered?(call)
262
+ @chat.to_llm.messages.any? { |m| m.role == :tool && m.tool_call_id == call.id }
263
+ end
264
+
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.
268
+ def replay_completed_action(action, call)
269
+ return false unless action.completed?
270
+
271
+ unless answered?(call)
272
+ inject_result(call, action.output&.fetch("content", nil).to_s)
273
+ session.emit("action.result", {
274
+ turn_id: turn.id, call_id: call.id, tool: call.name, status: "completed"
275
+ })
276
+ end
277
+ true
278
+ end
279
+
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.
286
+ def execute_action(action, call)
287
+ tool_class = definition.tools[call.name]
288
+ failure = nil
289
+ content =
290
+ if tool_class
291
+ arguments, argument_errors = Arguments.coerce(tool_class, call.arguments)
292
+ if argument_errors.any?
293
+ failure = Xeno::Error.new("invalid arguments: #{argument_errors.join('; ')}")
294
+ JSON.generate({ error: "invalid arguments for #{call.name}", details: argument_errors })
295
+ else
296
+ begin
297
+ result = ActiveSupport::Notifications.instrument("xeno.action", turn_id: turn.id, tool: call.name) do
298
+ tool = tool_class.new
299
+ tool.session = session
300
+ with_heartbeat { tool.call(arguments) }
301
+ end
302
+ stringify_result(result)
303
+ rescue StandardError => e
304
+ failure = e
305
+ JSON.generate({ error: "#{call.name} raised #{e.class.name}: #{e.message}" })
306
+ end
307
+ end
308
+ else
309
+ JSON.generate({ error: "unknown tool: #{call.name}" })
310
+ end
311
+
312
+ turn.heartbeat!(token)
313
+ ActiveRecord::Base.transaction do
314
+ action.update!(status: failure ? "failed" : "completed", output: { "content" => content })
315
+ inject_result(call, content)
316
+ end
317
+ if failure
318
+ session.emit("step.failed", { turn_id: turn.id, tool: call.name, error: failure.message })
319
+ end
320
+ session.emit("action.result", {
321
+ turn_id: turn.id, call_id: call.id, tool: call.name, status: failure ? "failed" : "completed"
322
+ })
323
+ end
324
+
325
+ # A failed action is a checkpoint too: the recorded error result is
326
+ # injected on replay — the tool is never re-executed.
327
+ def replay_failed_action(action, call)
328
+ return false unless action.status == "failed"
329
+
330
+ unless answered?(call)
331
+ inject_result(call, action.output&.fetch("content", nil).to_s)
332
+ session.emit("action.result", {
333
+ turn_id: turn.id, call_id: call.id, tool: call.name, status: "failed"
334
+ })
335
+ end
336
+ true
337
+ end
338
+
339
+ def inject_result(call, content)
340
+ @chat.add_message(role: :tool, content: content, tool_call_id: call.id)
341
+ end
342
+
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.
349
+ def with_heartbeat
350
+ 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.
353
+ return yield unless interval.positive?
354
+
355
+ stop = Thread::Queue.new
356
+ beater = Thread.new do
357
+ loop do
358
+ break if stop.pop(timeout: interval)
359
+ break unless beat_quietly
360
+ end
361
+ end
362
+ yield
363
+ ensure
364
+ if beater
365
+ stop.push(:stop)
366
+ beater.join(2)
367
+ end
368
+ end
369
+
370
+ def beat_quietly
371
+ ActiveRecord::Base.connection_pool.with_connection { turn.heartbeat!(token) }
372
+ true
373
+ rescue Xeno::Fenced
374
+ false
375
+ rescue StandardError
376
+ true # a transient DB hiccup must not kill the beat
377
+ end
378
+
379
+ def stringify_result(result)
380
+ content, _attachments = RubyLLM::Tool.split_result(result)
381
+ content
382
+ end
383
+
384
+ def generate_step
385
+ enforce_budgets!
386
+ step_index = turn.record_step!(token)
387
+ session.emit("step.started", { turn_id: turn.id, step: step_index })
388
+
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.
391
+ streamer = Channels.streamer_for(session)
392
+ response = ActiveSupport::Notifications.instrument("xeno.step", turn_id: turn.id, step: step_index) do
393
+ with_heartbeat do
394
+ if streamer
395
+ @chat.generate { |chunk| streamer.push(chunk) }
396
+ else
397
+ @chat.generate
398
+ end
399
+ end
400
+ end
401
+
402
+ session.emit("step.completed", {
403
+ turn_id: turn.id, step: step_index,
404
+ tool_calls: (response.tool_calls || {}).values.map { |c| { id: c.id, name: c.name } }
405
+ })
406
+
407
+ if response.tool_call?
408
+ session.emit("actions.requested", {
409
+ turn_id: turn.id,
410
+ calls: response.tool_calls.values.map { |c| { id: c.id, name: c.name, arguments: c.arguments } }
411
+ })
412
+ else
413
+ 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.
416
+ @stream_delivered = true if streamer&.finish(response.content)
417
+ end
418
+
419
+ response
420
+ end
421
+
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.
428
+ def enforce_budgets!
429
+ check_budget_axis!("input", :input_tokens, Xeno.config.max_input_tokens_per_session)
430
+ check_budget_axis!("output", :output_tokens, Xeno.config.max_output_tokens_per_session)
431
+ end
432
+
433
+ def check_budget_axis!(axis, column, global)
434
+ limit = definition.config.token_limit(column, global)
435
+ return unless limit
436
+
437
+ used = @chat.ruby_llm_usages.sum(column).to_i
438
+ raise Xeno::BudgetExceeded.new(axis: axis, used: used, limit: limit) if used >= limit
439
+ end
440
+
441
+ def emit_completion_events(message)
442
+ if message.thinking&.text.present?
443
+ session.emit("reasoning.completed", { turn_id: turn.id, thinking: message.thinking.text })
444
+ end
445
+ session.emit("message.completed", { turn_id: turn.id, content: message.content })
446
+ end
447
+
448
+ def complete_turn
449
+ turn.fenced_update!(token, status: "completed", heartbeat_at: Time.current)
450
+ session.emit("turn.completed", { turn_id: turn.id, sequence: turn.sequence })
451
+ deliver_final_answer
452
+ maybe_stage_compaction
453
+ advance_session
454
+ end
455
+
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).
459
+ def deliver_final_answer
460
+ return if @stream_delivered
461
+
462
+ final = @chat.messages_association.where(role: "assistant").order(:id).last
463
+ Channels.deliver_completion(session, final.content) if final&.content.present?
464
+ end
465
+
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.
470
+ def advance_session
471
+ return unless session.reload.active?
472
+
473
+ session.drain_pending_messages!(definition: definition)
474
+ session.turns.where(status: "pending").order(:sequence).first&.enqueue!
475
+ end
476
+
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.
481
+ def park_turn(actions = [])
482
+ turn.fenced_update!(token, status: "waiting")
483
+ session.update!(status: "waiting")
484
+ session.emit("session.waiting", { turn_id: turn.id })
485
+ session.drain_pending_messages!(definition: definition)
486
+ Channels.deliver_input_request(session, actions) if actions.any?
487
+ end
488
+
489
+ def fail_schedule_park
490
+ session.settle_unanswered_tool_calls!(turn, reason: "schedule runs cannot wait for human input")
491
+ fail_turn(Xeno::Error.new("schedule run required human input (approval or question)"))
492
+ end
493
+
494
+ def cancel_turn
495
+ session.settle_unanswered_tool_calls!(turn, reason: "cancelled by user")
496
+ turn.fenced_update!(token, status: "cancelled")
497
+ session.emit("turn.cancelled", { turn_id: turn.id })
498
+ advance_session
499
+ end
500
+
501
+ def fail_turn(error)
502
+ turn.fenced_update!(token, status: "failed", error: { "class" => error.class.name, "message" => error.message })
503
+ session.emit("turn.failed", { turn_id: turn.id, error: error.message })
504
+ advance_session
505
+ end
506
+
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.
510
+ def release_for_retry(error)
511
+ turn.release_for_retry!(token, error)
512
+ rescue Xeno::Fenced
513
+ nil
514
+ end
515
+
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.
520
+ def checkpoint_shutdown!
521
+ raise Xeno::Interrupted, "worker is shutting down" if stopping?
522
+ end
523
+
524
+ def stopping?
525
+ check = Xeno.config.stopping_check
526
+ return !!check.call if check
527
+
528
+ TurnJob.queue_adapter.stopping?
529
+ rescue NoMethodError
530
+ false # an adapter without the probe never interrupts
531
+ end
532
+
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.
538
+ def interrupt_turn
539
+ turn.fenced_update!(token, status: "pending", heartbeat_at: Time.current)
540
+ turn.enqueue!
541
+ rescue Xeno::Fenced
542
+ nil
543
+ end
544
+ end
545
+ end
@@ -0,0 +1,3 @@
1
+ module Xeno
2
+ VERSION = "0.0.1"
3
+ end
data/lib/xeno.rb ADDED
@@ -0,0 +1,117 @@
1
+ require "ruby_llm"
2
+
3
+ require "xeno/version"
4
+ require "xeno/errors"
5
+ require "xeno/configuration"
6
+ require "xeno/agent_config"
7
+ require "xeno/agent_definition"
8
+ require "xeno/tool"
9
+ require "xeno/ask_question"
10
+ require "xeno/approval_context"
11
+ require "xeno/inputs"
12
+ require "xeno/arguments"
13
+ require "xeno/session_state"
14
+ require "xeno/channels"
15
+ require "xeno/channels/slack"
16
+ require "xeno/hooks"
17
+ require "xeno/compaction"
18
+ require "xeno/turn_runner"
19
+ require "xeno/reaper"
20
+ require "xeno/engine"
21
+
22
+ module Xeno
23
+ class << self
24
+ # The app's agent directory. Defaults to <Rails.root>/agent; overridable
25
+ # (standalone mode, tests).
26
+ attr_writer :agent_root
27
+
28
+ def agent_root
29
+ @agent_root ||= Rails.root.join("agent")
30
+ end
31
+
32
+ # The agent/agent.rb entry point:
33
+ #
34
+ # Xeno.agent do
35
+ # model "anthropic/claude-sonnet-5"
36
+ # end
37
+ #
38
+ def agent(&block)
39
+ config = AgentConfig.new
40
+ config.instance_eval(&block) if block
41
+ @captured_agent_config = config
42
+ end
43
+
44
+ def capture_agent_config # :nodoc: loader bookkeeping around `load agent.rb`
45
+ @captured_agent_config = nil
46
+ yield
47
+ end
48
+
49
+ def captured_agent_config # :nodoc:
50
+ @captured_agent_config
51
+ end
52
+
53
+ # The agent/instructions.rb entry point — dynamic instructions resolved
54
+ # at turn-stage time with the session context:
55
+ #
56
+ # Xeno.instructions do |context|
57
+ # "You are helping #{context.principal&.dig("name") || "a guest"}."
58
+ # end
59
+ #
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
+ def instructions(&block)
63
+ @captured_instructions = block
64
+ end
65
+
66
+ def capture_instructions # :nodoc: loader bookkeeping around `load instructions.rb`
67
+ @captured_instructions = nil
68
+ yield
69
+ end
70
+
71
+ def captured_instructions # :nodoc:
72
+ @captured_instructions
73
+ end
74
+
75
+ # The agent/hooks/*.rb entry point — observe-only handlers for stream
76
+ # events (any type from the vocabulary, or "*"):
77
+ #
78
+ # Xeno.hook "turn.completed" do |event|
79
+ # Metrics.increment("agent.turns")
80
+ # end
81
+ #
82
+ # Handlers fire after the event commits, at-least-once (replays
83
+ # re-emit). Raising never breaks the runtime.
84
+ def hook(event_type, &block)
85
+ raise ArgumentError, "Xeno.hook requires a block" unless block
86
+
87
+ (@captured_hooks ||= {})[event_type.to_s] ||= []
88
+ @captured_hooks[event_type.to_s] << block
89
+ end
90
+
91
+ def capture_hooks # :nodoc: loader bookkeeping around `load hooks/*.rb`
92
+ @captured_hooks = {}
93
+ yield
94
+ @captured_hooks
95
+ end
96
+
97
+ # The resolved agent definition, discovered on first use and cached.
98
+ # Rails reloading resets it (see engine's to_prepare).
99
+ def definition
100
+ @definition ||= AgentDefinition.load(agent_root, name: default_agent_name)
101
+ end
102
+
103
+ def reset_definition!
104
+ @definition = nil
105
+ end
106
+
107
+ def default_agent_name
108
+ Rails.application.class.module_parent_name.underscore
109
+ end
110
+
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
+ def dev_routes_enabled?
114
+ Rails.env.development? || %w[1 true].include?(ENV["XENO_DEV_ROUTES"].to_s)
115
+ end
116
+ end
117
+ end