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,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
|
data/lib/xeno/version.rb
ADDED
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
|