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,122 @@
1
+ module Xeno
2
+ # The session event stream: SSE by default, NDJSON on request
3
+ # (`?format=ndjson` or `Accept: application/x-ndjson` — one envelope per
4
+ # line, friendlier to curl/jq and non-browser consumers). `?start_index=N`
5
+ # rewinds/resumes — events are durable rows, so reconnecting clients
6
+ # replay history and then follow live. The stream ends when the session
7
+ # reaches a terminal status (or the optional configured max duration).
8
+ #
9
+ # Known limitation (documented): ActionController::Live holds a thread
10
+ # per connected client.
11
+ class StreamsController < ApiController
12
+ include ActionController::Live
13
+
14
+ KEEPALIVE_INTERVAL = 5 # seconds without a write before a ping
15
+
16
+ # SSE framing: `id:`/`event:` headers per frame, comment keepalives.
17
+ class SseWriter
18
+ def initialize(stream)
19
+ @stream = stream
20
+ @sse = ActionController::Live::SSE.new(stream)
21
+ end
22
+
23
+ def content_type = "text/event-stream"
24
+ def event(record) = @sse.write(record.envelope, event: record.event_type, id: record.index)
25
+ def keepalive = @stream.write(": keepalive\n\n")
26
+ def close = @sse.close
27
+ end
28
+
29
+ # NDJSON framing: one envelope per line (type/index live inside the
30
+ # envelope); a bare newline as keepalive — line parsers skip empties.
31
+ class NdjsonWriter
32
+ def initialize(stream)
33
+ @stream = stream
34
+ end
35
+
36
+ def content_type = "application/x-ndjson"
37
+ def event(record) = @stream.write("#{JSON.generate(record.envelope)}\n")
38
+ def keepalive = @stream.write("\n")
39
+ def close = @stream.close
40
+ end
41
+
42
+ def show
43
+ session = find_owned_session!
44
+ writer = build_writer
45
+ response.headers["Content-Type"] = writer.content_type
46
+ response.headers["Last-Modified"] = Time.now.httpdate # disable buffering middlewares
47
+
48
+ cursor = params.fetch(:start_index, 0).to_i
49
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
50
+ last_write = started
51
+
52
+ batch_limit = Xeno.config.stream_catch_up_batch
53
+ loop do
54
+ events = session.events.where(index: cursor..).order(:index).limit(batch_limit).to_a
55
+ events.each do |event|
56
+ writer.event(event)
57
+ cursor = event.index + 1
58
+ end
59
+ last_write = Process.clock_gettime(Process::CLOCK_MONOTONIC) if events.any?
60
+
61
+ # A full batch means more history is waiting — keep paging through
62
+ # the catch-up without sleeping or ending on a terminal status.
63
+ next if events.size == batch_limit
64
+
65
+ break unless session.reload.active?
66
+ break if stream_expired?(started)
67
+ # A graceful stop must not wait out the in-flight-request window —
68
+ # the stream is resumable by design (events are durable rows, the
69
+ # client reconnects with its cursor), so close it and let the
70
+ # server exit. The 5s force cap in the puma config stays as the
71
+ # backstop.
72
+ break if server_shutting_down?
73
+
74
+ # A quiet stream never writes, so a dead client would never raise and
75
+ # this thread would poll forever — keepalives make disconnects visible.
76
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) - last_write > KEEPALIVE_INTERVAL
77
+ writer.keepalive
78
+ last_write = Process.clock_gettime(Process::CLOCK_MONOTONIC)
79
+ end
80
+
81
+ sleep Xeno.config.stream_poll_interval
82
+ end
83
+ rescue ActionController::Live::ClientDisconnected, IOError
84
+ # the client went away — nothing to clean up, events are durable
85
+ ensure
86
+ writer&.close
87
+ end
88
+
89
+ private
90
+
91
+ def build_writer
92
+ if params[:format] == "ndjson" || request.headers["Accept"].to_s.include?("application/x-ndjson")
93
+ NdjsonWriter.new(response.stream)
94
+ else
95
+ SseWriter.new(response.stream)
96
+ end
97
+ end
98
+
99
+ def stream_expired?(started)
100
+ max = Xeno.config.stream_max_duration
101
+ max && Process.clock_gettime(Process::CLOCK_MONOTONIC) - started > max
102
+ end
103
+
104
+ def server_shutting_down?
105
+ check = Xeno.config.stream_shutdown_check
106
+ return !!instance_exec(&check) if check
107
+
108
+ !!puma_server&.shutting_down?
109
+ rescue StandardError
110
+ false
111
+ end
112
+
113
+ # ActionController::Live runs the action on its OWN thread, so Puma's
114
+ # thread-local Server.current is nil here (verified against a live
115
+ # server). Find the process's server instance once per connection.
116
+ def puma_server
117
+ return nil unless defined?(Puma::Server)
118
+
119
+ @puma_server ||= Puma::Server.current || ObjectSpace.each_object(Puma::Server).first
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,4 @@
1
+ module Xeno
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Xeno
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,12 @@
1
+ module Xeno
2
+ # Periodic sweep for turns with no live owner (see Xeno::Reaper). Fired
3
+ # by Solid Queue's recurring machinery, a cron hitting `xeno:reap`, or
4
+ # any other scheduler — running it twice is harmless.
5
+ class ReaperJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ def perform
9
+ Reaper.sweep!
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,56 @@
1
+ module Xeno
2
+ # Fires one scheduled run: a fire-and-forget task-mode session under the
3
+ # app principal. Solid Queue recurring entries (rake xeno:schedules:sync)
4
+ # and the dev dispatch endpoint both enqueue this.
5
+ #
6
+ # At-least-once safe: queues redeliver, and Solid Queue's recurring
7
+ # (task, run-at) dedup only covers the enqueue, not the execution. Each
8
+ # enqueue carries one ActiveJob job_id; the dedup row claims it in the
9
+ # SAME transaction that opens the session, so a redelivered tick can
10
+ # never double-open — it re-enqueues the recorded turn instead (idle
11
+ # no-op if the first delivery finished: the claim CAS decides).
12
+ class ScheduleJob < ApplicationJob
13
+ queue_as :default
14
+
15
+ discard_on ArgumentError
16
+
17
+ DEDUP_SCOPE = "schedule_run".freeze
18
+
19
+ def perform(name)
20
+ definition = Xeno.definition
21
+ schedule = definition.schedules[name]
22
+ raise ArgumentError, "unknown schedule: #{name}" unless schedule
23
+
24
+ turn = nil
25
+ claimed = Session.transaction do
26
+ Dedup.claim(DEDUP_SCOPE, job_id, metadata: { "schedule" => name }).tap do |row|
27
+ next unless row
28
+
29
+ session = Session.open!(
30
+ message: schedule.prompt,
31
+ channel: "schedule",
32
+ principal: { "type" => "app", "schedule" => name },
33
+ definition: definition
34
+ )
35
+ turn = session.turns.order(:sequence).last
36
+ row.update!(metadata: row.metadata.merge("session_id" => session.id, "turn_id" => turn.id))
37
+ end
38
+ end
39
+
40
+ turn ||= redelivered_turn(claimed)
41
+ TurnJob.perform_now(turn.id) if turn
42
+ end
43
+
44
+ private
45
+
46
+ # This job_id already ran (or died mid-run): pick up ITS turn instead of
47
+ # opening a second session. The claim CAS makes this a no-op when the
48
+ # first delivery actually finished.
49
+ def redelivered_turn(claimed)
50
+ return nil if claimed
51
+
52
+ turn_id = Dedup.existing(DEDUP_SCOPE, job_id)&.metadata&.fetch("turn_id", nil)
53
+ Turn.find_by(id: turn_id) if turn_id
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,20 @@
1
+ module Xeno
2
+ # Processes one verified Slack event off the webhook path. The endpoint
3
+ # acks inside Slack's 3-second window and hands the payload here; session
4
+ # work (DB writes, LLM turns via TurnJob) happens on the queue. Slack
5
+ # retry storms die at the event_id dedup row (claimed atomically with
6
+ # this enqueue); a session can never be double-opened by a retry — the
7
+ # thread's continuation token is unique among active sessions.
8
+ class SlackEventJob < ApplicationJob
9
+ queue_as :default
10
+
11
+ discard_on ActiveJob::DeserializationError
12
+
13
+ def perform(payload)
14
+ channel = Channels.registry[:slack]
15
+ return unless channel
16
+
17
+ channel.handle_event(payload)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ module Xeno
2
+ # One turn = one job. All correctness (claim, fencing, replay) lives in
3
+ # TurnRunner + the database; the queue only provides at-least-once
4
+ # delivery and retry backoff.
5
+ class TurnJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ retry_on StandardError, wait: :polynomially_longer, attempts: 5
9
+ discard_on ActiveJob::DeserializationError, ActiveRecord::RecordNotFound
10
+
11
+ def perform(turn_id)
12
+ turn = Turn.find(turn_id)
13
+ TurnRunner.new(turn).run
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,6 @@
1
+ module Xeno
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,26 @@
1
+ module Xeno
2
+ # One requested tool call: the durability checkpoint and (in M4) the
3
+ # approval state. A completed action means "executed AND its result is in
4
+ # the transcript" — the two are committed in one transaction, so replay
5
+ # logic can trust either signal.
6
+ class Action < ApplicationRecord
7
+ STATUSES = %w[pending pending_approval approved denied completed failed].freeze
8
+ KINDS = %w[tool question].freeze
9
+
10
+ belongs_to :turn, class_name: "Xeno::Turn"
11
+
12
+ validates :status, inclusion: { in: STATUSES }
13
+ validates :kind, inclusion: { in: KINDS }
14
+
15
+ def self.record!(turn, tool_call, kind: "tool")
16
+ find_or_create_by!(turn: turn, tool_call_id: tool_call.id) do |action|
17
+ action.tool_name = tool_call.name
18
+ action.kind = kind
19
+ action.input = tool_call.arguments
20
+ end
21
+ end
22
+
23
+ def completed? = status == "completed"
24
+ def awaiting_input? = %w[pending_approval].include?(status)
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ module Xeno
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,22 @@
1
+ module Xeno
2
+ # The persisted RubyLLM chat backing one session's transcript.
3
+ # Owned by xeno — host apps with their own acts_as chat classes are
4
+ # unaffected; the message class below pins the transcript inside the
5
+ # engine namespace. Models, tool calls, usage, and batches are RubyLLM
6
+ # library records (ruby_llm_* tables), polymorphic toward this class.
7
+ class Chat < ApplicationRecord
8
+ acts_as_chat message_class: "Xeno::Message"
9
+
10
+ # assume_model_exists and protocol are runtime attributes, not columns —
11
+ # a freshly loaded record loses them, and the first to_llm build (which
12
+ # with_instructions triggers) then resolves the model strictly and with
13
+ # the provider's default protocol: ModelNotFoundError on the SECOND
14
+ # message of any session using a custom/OpenAI-compatible model
15
+ # (finding K). Every entry point re-applies them from the definition.
16
+ def apply_runtime_options!(model_options)
17
+ self.assume_model_exists = model_options[:assume_model_exists] if model_options.key?(:assume_model_exists)
18
+ self.protocol = model_options[:protocol] if model_options.key?(:protocol)
19
+ self
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+ module Xeno
2
+ # The at-least-once dedup ledger. Redelivered work (a cron tick fired
3
+ # twice, a Slack retry storm) claims its (scope, key) once; every other
4
+ # delivery loses the unique-index race and can read what the winner
5
+ # recorded in metadata.
6
+ class Dedup < ApplicationRecord
7
+ validates :scope, :key, presence: true
8
+
9
+ # Returns the freshly claimed row, or nil when the key was already
10
+ # claimed. Safe inside enclosing transactions (savepoint), safe under
11
+ # concurrency (the unique index decides).
12
+ def self.claim(scope, key, metadata: {})
13
+ transaction(requires_new: true) do
14
+ create!(scope: scope, key: key, metadata: metadata)
15
+ end
16
+ rescue ActiveRecord::RecordNotUnique
17
+ nil
18
+ end
19
+
20
+ def self.existing(scope, key)
21
+ find_by(scope: scope, key: key)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,63 @@
1
+ module Xeno
2
+ # The append-only session stream — also the audit log. `index` is the
3
+ # per-session cursor; consumers dedupe
4
+ # re-emitted events by (session, index).
5
+ class Event < ApplicationRecord
6
+ belongs_to :session, class_name: "Xeno::Session"
7
+
8
+ # The fixed vocabulary (docs/runtime.md § Events). Hooks may subscribe
9
+ # to any of these or "*".
10
+ TYPES = %w[
11
+ session.started turn.started message.received
12
+ step.started step.completed step.failed
13
+ actions.requested action.result input.requested
14
+ reasoning.completed message.completed
15
+ compaction.requested compaction.completed
16
+ budget.exceeded
17
+ turn.completed turn.failed turn.cancelled
18
+ session.waiting session.completed session.failed
19
+ ].freeze
20
+
21
+ # Hooks fire only after the row is durably committed (and therefore
22
+ # visible to other processes) — never inside the emitting transaction.
23
+ after_create_commit { Hooks.dispatch(self) }
24
+
25
+ # Appends with a race-safe per-session index: concurrent writers collide
26
+ # on the unique index and retry with the next slot. The INSERT runs in
27
+ # its own savepoint (requires_new): emit is routinely called inside
28
+ # enclosing transactions, and on Postgres a failed INSERT otherwise
29
+ # aborts the whole transaction — the retry would raise
30
+ # PG::InFailedSqlTransaction instead of recovering.
31
+ def self.append!(session, event_type, data = {})
32
+ attempts = 0
33
+ begin
34
+ next_index = (where(session_id: session.id).maximum(:index) || -1) + 1
35
+ transaction(requires_new: true) do
36
+ create!(
37
+ session_id: session.id,
38
+ index: next_index,
39
+ event_type: event_type,
40
+ data: data,
41
+ created_at: Time.current
42
+ )
43
+ end
44
+ rescue ActiveRecord::RecordNotUnique
45
+ # Every collision means a competing append SUCCEEDED, so retries are
46
+ # bounded by actual contention. SQLite's file lock serializes writers
47
+ # (collisions are rare); Postgres runs them truly concurrently, so a
48
+ # burst can cost more than a handful of rounds — back off with jitter
49
+ # instead of giving up early.
50
+ attempts += 1
51
+ raise if attempts >= 50
52
+
53
+ sleep(rand * 0.002 * attempts)
54
+ retry
55
+ end
56
+ end
57
+
58
+ # The wire envelope: { type, data, meta: { index, at } }.
59
+ def envelope
60
+ { "type" => event_type, "data" => data || {}, "meta" => { "index" => index, "at" => created_at.iso8601(3) } }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,5 @@
1
+ module Xeno
2
+ class Message < ApplicationRecord
3
+ acts_as_message chat_class: "Xeno::Chat"
4
+ end
5
+ end
@@ -0,0 +1,7 @@
1
+ module Xeno
2
+ # Messages that arrived while a turn was running. Drained into the next
3
+ # turn when the session parks or the turn completes.
4
+ class PendingMessage < ApplicationRecord
5
+ belongs_to :session, class_name: "Xeno::Session"
6
+ end
7
+ end
@@ -0,0 +1,231 @@
1
+ module Xeno
2
+ # The durable conversation: lives for days or weeks, owns the transcript
3
+ # chat, the turn ledger, and the append-only event stream. Identified by
4
+ # id (inspect/stream handle) and at most one continuation_token (the
5
+ # channel-owned resume handle, unique among active sessions).
6
+ class Session < ApplicationRecord
7
+ STATUSES = %w[running waiting completed failed].freeze
8
+
9
+ belongs_to :chat, class_name: "Xeno::Chat"
10
+ has_many :turns, class_name: "Xeno::Turn", dependent: :destroy
11
+ has_many :events, class_name: "Xeno::Event", dependent: :destroy
12
+ has_many :pending_messages, class_name: "Xeno::PendingMessage", dependent: :destroy
13
+
14
+ validates :status, inclusion: { in: STATUSES }
15
+
16
+ # Opens a session for a first user message: builds the transcript chat
17
+ # per the resolved agent definition, stamps channel + principal, and
18
+ # stages turn 1. The caller enqueues the returned turn's job after the
19
+ # transaction commits (Session.start! does both).
20
+ def self.open!(message:, channel: nil, principal: nil, continuation_token: nil, definition: Xeno.definition)
21
+ transaction do
22
+ chat = Chat.new
23
+ options = definition.config.model_options
24
+ chat.apply_runtime_options!(options)
25
+ chat.provider = options[:provider].to_s if options[:provider]
26
+ chat.model = definition.config.resolved_model
27
+ chat.save!
28
+
29
+ session = create!(
30
+ agent: definition.name,
31
+ chat: chat,
32
+ channel: channel,
33
+ principal: principal,
34
+ continuation_token: continuation_token
35
+ )
36
+ session.emit("session.started", { agent: definition.name, channel: channel })
37
+ session.stage_turn!(message, definition: definition)
38
+ session
39
+ end
40
+ end
41
+
42
+ # Convenience for callers without transactional needs: open + enqueue.
43
+ def self.start!(message:, **options)
44
+ session = open!(message: message, **options)
45
+ session.turns.last.enqueue!
46
+ session
47
+ end
48
+
49
+ # Stages the next turn for one or more messages: persists each user
50
+ # message into the transcript, refreshes instructions (each turn picks up
51
+ # edited instructions without a restart), and
52
+ # appends the turn row. Runs in the caller's process so TurnJob replays
53
+ # never double-add the user message.
54
+ #
55
+ # defer_transcript: the turn row carries the messages but nothing is
56
+ # written into the transcript yet — the runner stages it when the turn
57
+ # is claimed. Required whenever an EARLIER turn may still be mid-flight
58
+ # (drained turns): appending user rows behind a parked turn's unanswered
59
+ # tool calls would wedge every later generate (providers demand tool
60
+ # results immediately after the assistant's tool_calls message).
61
+ def stage_turn!(messages, definition: Xeno.definition, emit_received: true, defer_transcript: false)
62
+ contents = Array(messages)
63
+ transaction do
64
+ unless defer_transcript
65
+ # Finding K: with_instructions triggers the first to_llm build; a
66
+ # fresh chat record must get its runtime options back first.
67
+ chat.apply_runtime_options!(definition.config.model_options)
68
+ resolved = definition.instructions_for(session: self)
69
+ chat.with_instructions(resolved) if resolved
70
+ contents.each { |content| chat.add_message(role: :user, content: content) }
71
+ end
72
+ turn = Turn.append!(self, user_message: { contents: contents }, transcript_deferred: defer_transcript)
73
+ if emit_received
74
+ contents.each { |content| emit("message.received", { content: content, turn_id: turn.id }) }
75
+ end
76
+ emit("turn.started", { turn_id: turn.id, sequence: turn.sequence })
77
+ turn
78
+ end
79
+ end
80
+
81
+ # The channel entry point for follow-up messages. While a turn is active
82
+ # (one active turn per session) the message queues in pending_messages
83
+ # and is folded into the next turn; a PARKED session drains immediately
84
+ # (the message becomes a visible staged turn instead of sitting invisible
85
+ # until someone resolves the input — mvp-design's delivery semantics).
86
+ # An idle session stages and enqueues a turn right away.
87
+ def receive_message!(content)
88
+ if turns.where(status: %w[pending running waiting]).exists?
89
+ transaction do
90
+ pending_messages.create!(payload: { "content" => content })
91
+ emit("message.received", { content: content, queued: true })
92
+ end
93
+ drain_pending_messages! if reload.status == "waiting"
94
+ nil
95
+ else
96
+ turn = stage_turn!(content)
97
+ turn.enqueue!
98
+ turn
99
+ end
100
+ end
101
+
102
+ # Folds every queued message into ONE next turn. Called when a turn
103
+ # parks or reaches a terminal status (and on messages to a parked
104
+ # session). Always defers the transcript: the drained turn may sit
105
+ # behind a parked turn whose tool calls are still unanswered, so its
106
+ # user rows are written only when the runner claims it — turn order and
107
+ # transcript order can never diverge. Row locks make a racing drain
108
+ # (park vs. incoming message) fold each message exactly once.
109
+ def drain_pending_messages!(definition: Xeno.definition)
110
+ turn = nil
111
+ transaction do
112
+ queued = pending_messages.order(:id).lock.to_a
113
+ next if queued.empty?
114
+
115
+ contents = queued.map { |m| m.payload["content"] }
116
+ turn = stage_turn!(contents, definition: definition, emit_received: false, defer_transcript: true)
117
+ queued.each(&:destroy!)
118
+ end
119
+ turn
120
+ end
121
+
122
+ # Steering (opt-in per message): stop what the agent is doing and make
123
+ # THIS message the next turn. The active turn dies safely — a parked or
124
+ # pending turn settles its dangling tool calls (recorded answers
125
+ # injected, unapproved gates denied — the H1 machinery) and cancels
126
+ # immediately; a running turn cancels cooperatively (the steer message
127
+ # queues and the runner's cancel path folds it into the next turn at the
128
+ # next step boundary). If the cancel loses the race with a completing
129
+ # turn, steering degrades to a normal follow-up — never an error.
130
+ def steer!(content, definition: Xeno.definition)
131
+ turn = turns.where(status: %w[pending running waiting]).order(:sequence).first
132
+
133
+ case turn&.status
134
+ when nil # idle: steer is just a message
135
+ staged = stage_turn!(content, definition: definition)
136
+ staged.enqueue!
137
+ staged
138
+ when "running"
139
+ transaction do
140
+ pending_messages.create!(payload: { "content" => content })
141
+ emit("message.received", { content: content, queued: true, steer: true })
142
+ end
143
+ chat.cancel! # cooperative; the runner settles, cancels, and drains
144
+ nil
145
+ else # pending or waiting — no process owns it; replace it now
146
+ transaction do
147
+ settle_unanswered_tool_calls!(turn, reason: "steered by user")
148
+ turn.update!(status: "cancelled")
149
+ emit("turn.cancelled", { turn_id: turn.id, steer: true })
150
+ update!(status: "running") if status == "waiting"
151
+ end
152
+ staged = stage_turn!(content, definition: definition)
153
+ staged.enqueue!
154
+ staged
155
+ end
156
+ end
157
+
158
+ # Stages a compaction turn (summarize-and-replace, executed by the
159
+ # runner under a normal claim). It queues behind any active or parked
160
+ # turn via the ordinary session-ordering machinery — the
161
+ # queued-behind-the-active-turn semantics — and never appends a
162
+ # synthetic user message.
163
+ def stage_compaction_turn!(reason:, used: nil, limit: nil)
164
+ transaction do
165
+ request = { "reason" => reason, "used" => used, "limit" => limit }.compact
166
+ turn = Turn.append!(self, kind: "compaction", user_message: { "compaction" => request })
167
+ emit("compaction.requested", { "turn_id" => turn.id }.merge(request))
168
+ emit("turn.started", { turn_id: turn.id, sequence: turn.sequence })
169
+ turn
170
+ end
171
+ end
172
+
173
+ def emit(event_type, data = {})
174
+ Event.append!(self, event_type, data)
175
+ end
176
+
177
+ # Called before a turn dies with tool calls still unanswered (cancel
178
+ # while parked, schedule park failure): every dangling call gets a tool
179
+ # result in the transcript. Without this the assistant tool_call rows
180
+ # stay unanswered forever and every later generate is a provider 400 —
181
+ # the session is bricked. A completed action (e.g. an answered question
182
+ # whose resume never ran) injects its recorded output; everything else
183
+ # gets a denial, recorded on the action row as the audit trail.
184
+ def settle_unanswered_tool_calls!(turn, reason:)
185
+ chat_record = Chat.find(chat_id)
186
+ llm = chat_record.to_llm
187
+ last_assistant = llm.messages.reverse.find { |m| m.role == :assistant }
188
+ return unless last_assistant&.tool_call?
189
+
190
+ answered = llm.messages.select { |m| m.role == :tool }.map(&:tool_call_id).compact.to_set
191
+ last_assistant.tool_calls.values.reject { |call| answered.include?(call.id) }.each do |call|
192
+ action = turn.actions.find_by(tool_call_id: call.id)
193
+ if action&.completed?
194
+ content = action.output&.fetch("content", nil).to_s
195
+ status = "completed"
196
+ else
197
+ content = JSON.generate({ denied: true, reason: reason })
198
+ status = "denied"
199
+ end
200
+
201
+ transaction do
202
+ action.update!(status: "denied", output: { "content" => content }) if action && status == "denied"
203
+ chat_record.add_message(role: :tool, content: content, tool_call_id: call.id)
204
+ end
205
+ emit("action.result", { turn_id: turn.id, call_id: call.id, tool: call.name, status: status })
206
+ end
207
+ end
208
+
209
+ # The session-scoped KV store (see Xeno::SessionState).
210
+ def state
211
+ @state_handle ||= SessionState.new(self)
212
+ end
213
+
214
+ def active?
215
+ %w[running waiting].include?(status)
216
+ end
217
+
218
+ # Terminal states release the continuation token (a finished session must
219
+ # not squat a channel thread's token); a copy stays in metadata for audit.
220
+ def finish!(new_status)
221
+ raise ArgumentError, "not a terminal status: #{new_status}" unless %w[completed failed].include?(new_status)
222
+
223
+ transaction do
224
+ audit = (metadata || {}).merge("released_continuation_token" => continuation_token)
225
+ # State is working memory for the conversation; reset clears it.
226
+ update!(status: new_status, continuation_token: nil, metadata: audit, state: {})
227
+ emit("session.#{new_status}")
228
+ end
229
+ end
230
+ end
231
+ end