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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE +21 -0
- data/README.md +211 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/xeno/application.css +15 -0
- data/app/controllers/xeno/api_controller.rb +68 -0
- data/app/controllers/xeno/application_controller.rb +4 -0
- data/app/controllers/xeno/dev_controller.rb +24 -0
- data/app/controllers/xeno/dev_ui_controller.rb +71 -0
- data/app/controllers/xeno/health_controller.rb +10 -0
- data/app/controllers/xeno/sessions_controller.rb +131 -0
- data/app/controllers/xeno/slack_controller.rb +48 -0
- data/app/controllers/xeno/streams_controller.rb +122 -0
- data/app/helpers/xeno/application_helper.rb +4 -0
- data/app/jobs/xeno/application_job.rb +4 -0
- data/app/jobs/xeno/reaper_job.rb +12 -0
- data/app/jobs/xeno/schedule_job.rb +56 -0
- data/app/jobs/xeno/slack_event_job.rb +20 -0
- data/app/jobs/xeno/turn_job.rb +16 -0
- data/app/mailers/xeno/application_mailer.rb +6 -0
- data/app/models/xeno/action.rb +26 -0
- data/app/models/xeno/application_record.rb +5 -0
- data/app/models/xeno/chat.rb +22 -0
- data/app/models/xeno/dedup.rb +24 -0
- data/app/models/xeno/event.rb +63 -0
- data/app/models/xeno/message.rb +5 -0
- data/app/models/xeno/pending_message.rb +7 -0
- data/app/models/xeno/session.rb +231 -0
- data/app/models/xeno/turn.rb +125 -0
- data/app/views/layouts/xeno/application.html.erb +18 -0
- data/app/views/xeno/dev_ui/_styles.html.erb +24 -0
- data/app/views/xeno/dev_ui/index.html.erb +28 -0
- data/app/views/xeno/dev_ui/show.html.erb +115 -0
- data/config/routes.rb +25 -0
- data/db/migrate/20260804000001_create_xeno_llm_tables.rb +70 -0
- data/db/migrate/20260804000002_create_xeno_orchestration_tables.rb +70 -0
- data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +8 -0
- data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +8 -0
- data/db/migrate/20260805000003_create_xeno_dedups.rb +14 -0
- data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +9 -0
- data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +8 -0
- data/db/migrate/20260806000001_move_transcript_support_tables_to_ruby_llm.rb +133 -0
- data/docs/runtime.md +275 -0
- data/exe/xeno +133 -0
- data/lib/generators/xeno/install/install_generator.rb +51 -0
- data/lib/generators/xeno/install/templates/agent.rb +4 -0
- data/lib/generators/xeno/install/templates/initializer.rb +20 -0
- data/lib/generators/xeno/install/templates/instructions.md +6 -0
- data/lib/generators/xeno/tool/templates/tool.rb.tt +16 -0
- data/lib/generators/xeno/tool/tool_generator.rb +13 -0
- data/lib/tasks/xeno_tasks.rake +24 -0
- data/lib/xeno/agent_config.rb +66 -0
- data/lib/xeno/agent_definition.rb +286 -0
- data/lib/xeno/approval_context.rb +4 -0
- data/lib/xeno/arguments.rb +62 -0
- data/lib/xeno/ask_question.rb +18 -0
- data/lib/xeno/channels/slack.rb +311 -0
- data/lib/xeno/channels.rb +68 -0
- data/lib/xeno/compaction.rb +165 -0
- data/lib/xeno/configuration.rb +118 -0
- data/lib/xeno/engine.rb +29 -0
- data/lib/xeno/errors.rb +40 -0
- data/lib/xeno/hooks.rb +37 -0
- data/lib/xeno/info.rb +75 -0
- data/lib/xeno/inputs.rb +78 -0
- data/lib/xeno/reaper.rb +52 -0
- data/lib/xeno/schedules.rb +49 -0
- data/lib/xeno/session_state.rb +57 -0
- data/lib/xeno/standalone/local_secret.rb +26 -0
- data/lib/xeno/standalone/model_refresh.rb +26 -0
- data/lib/xeno/standalone/puma.rb +17 -0
- data/lib/xeno/standalone.rb +136 -0
- data/lib/xeno/tool.rb +73 -0
- data/lib/xeno/turn_runner.rb +545 -0
- data/lib/xeno/version.rb +3 -0
- data/lib/xeno.rb +117 -0
- 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,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,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,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,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
|