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,68 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# The channel registry. A channel (a) normalizes platform input into user
|
|
3
|
+
# messages, (b) owns the session's continuation token, and (c) decides
|
|
4
|
+
# delivery of agent output. HTTP is built in; agent/channels/*.rb declare
|
|
5
|
+
# the rest via the DSL:
|
|
6
|
+
#
|
|
7
|
+
# Xeno.channel :slack do
|
|
8
|
+
# signing_secret Rails.application.credentials.dig(:slack, :signing_secret)
|
|
9
|
+
# bot_token Rails.application.credentials.dig(:slack, :bot_token)
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# Delivery is a failable nicety: the durable truth lives in the transcript
|
|
13
|
+
# and event rows, so a failed post logs and moves on.
|
|
14
|
+
module Channels
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def registry
|
|
18
|
+
@registry ||= {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def register(name, channel)
|
|
22
|
+
registry[name.to_sym] = channel
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build(name, &block)
|
|
26
|
+
case name.to_sym
|
|
27
|
+
when :slack
|
|
28
|
+
Slack.new(&block)
|
|
29
|
+
else
|
|
30
|
+
raise Xeno::Error, "unknown channel type: #{name} (v0.1 ships :slack; http is built in)"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def for_session(session)
|
|
35
|
+
registry[session.channel&.to_sym]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def deliver_completion(session, content)
|
|
39
|
+
for_session(session)&.deliver_completion(session, content)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
Rails.logger.warn("xeno: channel delivery failed for session #{session.id}: #{e.class}: #{e.message}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def deliver_input_request(session, actions)
|
|
45
|
+
for_session(session)&.deliver_input_request(session, actions)
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
Rails.logger.warn("xeno: channel input-request delivery failed for session #{session.id}: #{e.class}: #{e.message}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A per-reply delta sink (post-then-edit streaming) when the session's
|
|
51
|
+
# channel opts in; nil otherwise. Failable nicety like all delivery.
|
|
52
|
+
def streamer_for(session)
|
|
53
|
+
channel = for_session(session)
|
|
54
|
+
return nil unless channel.respond_to?(:streamer_for)
|
|
55
|
+
|
|
56
|
+
channel.streamer_for(session)
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
Rails.logger.warn("xeno: channel streamer setup failed for session #{session.id}: #{e.class}: #{e.message}")
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.channel(name, &block)
|
|
64
|
+
channel = Channels.build(name, &block)
|
|
65
|
+
Channels.register(name, channel)
|
|
66
|
+
channel
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# Summarize-and-replace compaction, mixed into TurnRunner and executed as
|
|
3
|
+
# a claimed turn (kind: "compaction"). Riding the turn machinery buys the
|
|
4
|
+
# hard properties for free: the claim CAS makes it exclusive, session
|
|
5
|
+
# ordering queues it behind an active or parked turn, the heartbeat covers
|
|
6
|
+
# the summary model call, and a crash replays it (the applied result is
|
|
7
|
+
# recorded on the turn row, so replay never compacts twice).
|
|
8
|
+
#
|
|
9
|
+
# Shape: the system prompt survives untouched; the most recent
|
|
10
|
+
# `compaction_tail_turns` user-anchored turns survive verbatim (a tail cut
|
|
11
|
+
# at a user row can never separate an assistant's tool calls from their
|
|
12
|
+
# results); everything between is summarized by a throwaway model call
|
|
13
|
+
# (the active model, never persisted) and REPLACED — the summary is
|
|
14
|
+
# written into the earliest compacted row so both id- and created_at-
|
|
15
|
+
# ordering keep it in place, and the remaining rows are destroyed
|
|
16
|
+
# newest-first (results before the tool_calls they reference).
|
|
17
|
+
module Compaction
|
|
18
|
+
SUMMARY_PREFIX = "[Conversation summary — earlier messages were compacted]".freeze
|
|
19
|
+
|
|
20
|
+
COMPACTION_PROMPT = <<~PROMPT.freeze
|
|
21
|
+
You are compacting an agent conversation to reclaim context space.
|
|
22
|
+
Write a dense summary of the transcript below; it will REPLACE those
|
|
23
|
+
messages permanently. Separate completed work and decisions from
|
|
24
|
+
remaining or open work. Retain every constraint, preference, fact,
|
|
25
|
+
identifier, amount, and reference a future turn could need. Output
|
|
26
|
+
only the summary text.
|
|
27
|
+
PROMPT
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def run_compaction
|
|
32
|
+
unless compaction_applied?
|
|
33
|
+
rows = @chat.messages_association.order(:id).to_a
|
|
34
|
+
plan = compaction_plan(rows)
|
|
35
|
+
|
|
36
|
+
if plan[:compact].size < 2
|
|
37
|
+
record_compaction_result(compacted_messages: 0)
|
|
38
|
+
else
|
|
39
|
+
summary = with_heartbeat { compaction_summary(plan[:compact]) }
|
|
40
|
+
apply_compaction!(plan, summary)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
turn.fenced_update!(token, status: "completed", heartbeat_at: Time.current)
|
|
45
|
+
session.emit("turn.completed", { turn_id: turn.id, sequence: turn.sequence })
|
|
46
|
+
advance_session
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def compaction_applied?
|
|
50
|
+
(turn.user_message || {}).key?("result")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Everything strictly before the tail is compactable. The tail starts at
|
|
54
|
+
# the Nth-from-last user row; with too few turns there is nothing to do.
|
|
55
|
+
def compaction_plan(rows)
|
|
56
|
+
system_rows, convo = rows.partition { |row| row.role == "system" }
|
|
57
|
+
user_positions = convo.each_index.select { |i| convo[i].role == "user" }
|
|
58
|
+
tail_turns = [ Xeno.config.compaction_tail_turns.to_i, 1 ].max
|
|
59
|
+
|
|
60
|
+
if user_positions.size <= tail_turns
|
|
61
|
+
{ system: system_rows, compact: [], tail: convo }
|
|
62
|
+
else
|
|
63
|
+
tail_start = user_positions[-tail_turns]
|
|
64
|
+
{ system: system_rows, compact: convo[0...tail_start], tail: convo[tail_start..] }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# One model call on a throwaway chat — the active model with the agent's
|
|
69
|
+
# runtime options, no persistence, no tools. A previous summary (from an
|
|
70
|
+
# earlier compaction) is just an early row here and flows in whole.
|
|
71
|
+
def compaction_summary(rows)
|
|
72
|
+
options = definition.config.model_options
|
|
73
|
+
llm = RubyLLM.chat(
|
|
74
|
+
model: definition.config.resolved_model,
|
|
75
|
+
provider: options[:provider],
|
|
76
|
+
protocol: options[:protocol],
|
|
77
|
+
assume_model_exists: options.fetch(:assume_model_exists, false)
|
|
78
|
+
)
|
|
79
|
+
llm.with_instructions(COMPACTION_PROMPT)
|
|
80
|
+
response = llm.ask(render_compaction_transcript(rows))
|
|
81
|
+
response.content.to_s
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_compaction_transcript(rows)
|
|
85
|
+
rows.map { |row|
|
|
86
|
+
parts = [ "#{row.role}:" ]
|
|
87
|
+
parts << row.content if row.content.present?
|
|
88
|
+
if row.respond_to?(:ruby_llm_tool_calls) && row.tool_call?
|
|
89
|
+
row.ruby_llm_tool_calls.each { |call| parts << "[called #{call.name}(#{call.arguments.to_json})]" }
|
|
90
|
+
end
|
|
91
|
+
parts.join(" ")
|
|
92
|
+
}.join("\n\n")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The keeper (earliest compacted row) becomes the summary in place; the
|
|
96
|
+
# rest are destroyed newest-first. Rows, marker, and the
|
|
97
|
+
# compaction.completed event commit together — replay after any crash
|
|
98
|
+
# is a pure no-op past this transaction.
|
|
99
|
+
def apply_compaction!(plan, summary)
|
|
100
|
+
keeper, *rest = plan[:compact]
|
|
101
|
+
|
|
102
|
+
ActiveRecord::Base.transaction do
|
|
103
|
+
rest.reverse_each(&:destroy!)
|
|
104
|
+
# A parent tool call on a surviving earlier row would otherwise
|
|
105
|
+
# dangle a result FK at the repurposed keeper.
|
|
106
|
+
keeper.ruby_llm_tool_calls.destroy_all
|
|
107
|
+
keeper.ruby_llm_parent_tool_call&.update!(result: nil)
|
|
108
|
+
keeper.update!(
|
|
109
|
+
role: "user",
|
|
110
|
+
content: "#{SUMMARY_PREFIX}\n\n#{summary}",
|
|
111
|
+
thinking_text: nil, thinking_signature: nil
|
|
112
|
+
)
|
|
113
|
+
record_compaction_result(compacted_messages: plan[:compact].size)
|
|
114
|
+
end
|
|
115
|
+
sync_llm_messages
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def record_compaction_result(compacted_messages:)
|
|
119
|
+
ActiveRecord::Base.transaction do
|
|
120
|
+
turn.fenced_update!(token,
|
|
121
|
+
user_message: (turn.user_message || {}).merge("result" => { "compacted_messages" => compacted_messages }))
|
|
122
|
+
session.emit("compaction.completed", { turn_id: turn.id, compacted_messages: compacted_messages })
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Between-turns trigger: after a message turn completes, stage a
|
|
127
|
+
# compaction turn when the last model call's context usage crossed the
|
|
128
|
+
# threshold share of the model's window (config fallback when the
|
|
129
|
+
# registry doesn't know the window).
|
|
130
|
+
def maybe_stage_compaction
|
|
131
|
+
threshold = Xeno.config.compaction_threshold
|
|
132
|
+
return unless threshold
|
|
133
|
+
return if turn.kind == "compaction"
|
|
134
|
+
|
|
135
|
+
limit = compaction_context_limit
|
|
136
|
+
return unless limit
|
|
137
|
+
|
|
138
|
+
used = compaction_tokens_used
|
|
139
|
+
return unless used && used >= (limit * threshold.to_f)
|
|
140
|
+
return if session.turns.where(kind: "compaction", status: %w[pending running]).exists?
|
|
141
|
+
|
|
142
|
+
session.stage_compaction_turn!(reason: "auto", used: used, limit: limit)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def compaction_context_limit
|
|
146
|
+
window = begin
|
|
147
|
+
@chat.to_llm.model&.context_window
|
|
148
|
+
rescue StandardError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
window || Xeno.config.compaction_context_window
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# What the provider counted for the last completed call — the truthful
|
|
155
|
+
# "how full is the context" number, from the per-attempt usage ledger
|
|
156
|
+
# (message rows no longer carry token columns).
|
|
157
|
+
def compaction_tokens_used
|
|
158
|
+
last = @chat.ruby_llm_usages.where(operation: "chat", status: "succeeded")
|
|
159
|
+
.where.not(input_tokens: nil).order(:created_at, :id).last
|
|
160
|
+
return nil unless last
|
|
161
|
+
|
|
162
|
+
last.input_tokens.to_i + last.output_tokens.to_i
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# Gem-level runtime knobs, set from an initializer:
|
|
3
|
+
#
|
|
4
|
+
# Xeno.configure do |config|
|
|
5
|
+
# config.max_steps = 30
|
|
6
|
+
# end
|
|
7
|
+
#
|
|
8
|
+
class Configuration
|
|
9
|
+
# Per-turn model-call budget (RubyLLM's own loop is unbounded; ours is not).
|
|
10
|
+
attr_accessor :max_steps
|
|
11
|
+
|
|
12
|
+
# A turn whose claim ledger reaches this many attempts is poison — it
|
|
13
|
+
# fails instead of retrying forever.
|
|
14
|
+
attr_accessor :max_turn_attempts
|
|
15
|
+
|
|
16
|
+
# A running turn whose heartbeat is older than this is presumed dead and
|
|
17
|
+
# reclaimable. Checkpoint replay makes takeover safe; a too-short window
|
|
18
|
+
# risks overlap with a live-but-slow owner, a too-long one stalls the
|
|
19
|
+
# session after a hard crash.
|
|
20
|
+
attr_accessor :turn_stale_after
|
|
21
|
+
|
|
22
|
+
# How often the background heartbeat beats while the runner is inside a
|
|
23
|
+
# model call or a tool body (the calls that can outlast turn_stale_after).
|
|
24
|
+
# nil (default) = a quarter of turn_stale_after, so a live owner always
|
|
25
|
+
# beats several times per staleness window.
|
|
26
|
+
attr_writer :heartbeat_interval
|
|
27
|
+
|
|
28
|
+
# Per-session token budgets, enforced BEFORE each model call from the
|
|
29
|
+
# persisted usage columns (the provider's own counts). Checked
|
|
30
|
+
# independently per axis; the call that crosses is allowed to finish —
|
|
31
|
+
# the NEXT call trips. Exceeded → deterministic turn failure with a
|
|
32
|
+
# budget.exceeded event; the session recovers via reset. nil =
|
|
33
|
+
# unlimited. Per-agent override: `limits input_tokens:, output_tokens:`
|
|
34
|
+
# in agent.rb (false disables an axis).
|
|
35
|
+
attr_accessor :max_input_tokens_per_session, :max_output_tokens_per_session
|
|
36
|
+
|
|
37
|
+
# Compaction triggers when the last model call's context usage crosses
|
|
38
|
+
# this fraction of the model's context window. nil/false
|
|
39
|
+
# disables automatic compaction (manual stays available).
|
|
40
|
+
attr_accessor :compaction_threshold
|
|
41
|
+
|
|
42
|
+
# Fallback context window (tokens) for models the registry doesn't know
|
|
43
|
+
# (assume_model_exists). nil = automatic compaction never triggers for
|
|
44
|
+
# unknown-window models.
|
|
45
|
+
attr_accessor :compaction_context_window
|
|
46
|
+
|
|
47
|
+
# How many of the most recent user-message-anchored turns survive a
|
|
48
|
+
# compaction verbatim (the "recent tail"). Everything earlier is
|
|
49
|
+
# summarized and replaced.
|
|
50
|
+
attr_accessor :compaction_tail_turns
|
|
51
|
+
|
|
52
|
+
# Optional lambda answering "is the worker shutting down?" — probed by
|
|
53
|
+
# the runner between steps; when true the turn releases its claim and
|
|
54
|
+
# re-enqueues itself so a deploy-style stop resumes promptly on the next
|
|
55
|
+
# worker instead of waiting out stale-heartbeat reclaim. nil = ask the
|
|
56
|
+
# queue adapter (`queue_adapter.stopping?` — Solid Queue flips it on
|
|
57
|
+
# worker shutdown; adapters that don't implement it just return false).
|
|
58
|
+
attr_accessor :stopping_check
|
|
59
|
+
|
|
60
|
+
# Fail-closed HTTP auth lambda. Receives the request; a falsy return is
|
|
61
|
+
# a 401; a truthy return becomes the request principal.
|
|
62
|
+
attr_accessor :authenticate
|
|
63
|
+
|
|
64
|
+
# How often the SSE stream polls for new events, in seconds.
|
|
65
|
+
attr_accessor :stream_poll_interval
|
|
66
|
+
|
|
67
|
+
# How many events one stream poll reads at most. Bounds the catch-up
|
|
68
|
+
# read on long sessions (a reconnect from index 0 pages through history
|
|
69
|
+
# in batches instead of loading every row in one query).
|
|
70
|
+
attr_accessor :stream_catch_up_batch
|
|
71
|
+
|
|
72
|
+
# Optional hard cap on how long one SSE connection is served (seconds).
|
|
73
|
+
# nil = until the session reaches a terminal status.
|
|
74
|
+
attr_accessor :stream_max_duration
|
|
75
|
+
|
|
76
|
+
# Optional lambda answering "is the server shutting down?" — when true,
|
|
77
|
+
# open SSE streams close themselves so a graceful stop doesn't wait out
|
|
78
|
+
# the in-flight-request window (Ctrl-C stays near-instant with tabs
|
|
79
|
+
# open). nil = detect Puma's graceful stop automatically.
|
|
80
|
+
attr_accessor :stream_shutdown_check
|
|
81
|
+
|
|
82
|
+
def initialize
|
|
83
|
+
@max_steps = 20
|
|
84
|
+
@max_turn_attempts = 5
|
|
85
|
+
@turn_stale_after = 5 * 60 # seconds
|
|
86
|
+
@heartbeat_interval = nil
|
|
87
|
+
@max_input_tokens_per_session = nil
|
|
88
|
+
@max_output_tokens_per_session = nil
|
|
89
|
+
@compaction_threshold = 0.9
|
|
90
|
+
@compaction_context_window = nil
|
|
91
|
+
@compaction_tail_turns = 2
|
|
92
|
+
@stopping_check = nil
|
|
93
|
+
@authenticate = nil
|
|
94
|
+
@stream_poll_interval = 0.25
|
|
95
|
+
@stream_catch_up_batch = 500
|
|
96
|
+
@stream_max_duration = nil
|
|
97
|
+
@stream_shutdown_check = nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def turn_stale_after
|
|
101
|
+
@turn_stale_after.is_a?(Numeric) ? @turn_stale_after.seconds : @turn_stale_after
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def heartbeat_interval
|
|
105
|
+
@heartbeat_interval || turn_stale_after / 4.0
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class << self
|
|
110
|
+
def config
|
|
111
|
+
@config ||= Configuration.new
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def configure
|
|
115
|
+
yield config
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/xeno/engine.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace Xeno
|
|
4
|
+
|
|
5
|
+
# agent/ is a Zeitwerk root namespaced under Xeno with per-slot
|
|
6
|
+
# submodules: agent/tools/get_weather.rb defines Xeno::Tools::GetWeather.
|
|
7
|
+
# Non-constant files are xeno's to load: agent.rb is a config DSL,
|
|
8
|
+
# schedules/ holds markdown, channels/ is a DSL slot in v0.1, lib/ is
|
|
9
|
+
# plain shared Ruby (required manually by the author).
|
|
10
|
+
initializer "xeno.agent_autoload", before: :setup_main_autoloader do |app|
|
|
11
|
+
agent_root = Xeno.agent_root
|
|
12
|
+
if agent_root&.directory?
|
|
13
|
+
autoloader = Rails.autoloaders.main
|
|
14
|
+
autoloader.push_dir(agent_root, namespace: Xeno)
|
|
15
|
+
autoloader.ignore(agent_root.join("agent.rb"))
|
|
16
|
+
autoloader.ignore(agent_root.join("instructions.rb"))
|
|
17
|
+
%w[schedules channels hooks skills lib].each do |dir|
|
|
18
|
+
autoloader.ignore(agent_root.join(dir))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The cached definition holds tool classes; drop it whenever the app
|
|
24
|
+
# reloads so dev picks up edits under agent/.
|
|
25
|
+
config.to_prepare do
|
|
26
|
+
Xeno.reset_definition!
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/xeno/errors.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
# Raised when a fenced write discovers its claim was reaped — the runner
|
|
5
|
+
# is a zombie and must abort without touching anything else.
|
|
6
|
+
class Fenced < Error; end
|
|
7
|
+
|
|
8
|
+
# Raised (internally) to park the turn: pending actions need human input.
|
|
9
|
+
# Never RubyLLM's cancel! — its cleanup destroys in-flight rows.
|
|
10
|
+
class Parked < Error
|
|
11
|
+
attr_reader :actions
|
|
12
|
+
|
|
13
|
+
def initialize(message = "turn parked awaiting input", actions: [])
|
|
14
|
+
super(message)
|
|
15
|
+
@actions = actions
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The per-turn runaway guard tripped.
|
|
20
|
+
class MaxStepsExceeded < Error; end
|
|
21
|
+
|
|
22
|
+
# A session token budget is spent: raised BEFORE the model call that
|
|
23
|
+
# would overspend. Deterministic turn failure; reset recovers the session.
|
|
24
|
+
class BudgetExceeded < Error
|
|
25
|
+
attr_reader :axis, :used, :limit
|
|
26
|
+
|
|
27
|
+
def initialize(axis:, used:, limit:)
|
|
28
|
+
@axis = axis
|
|
29
|
+
@used = used
|
|
30
|
+
@limit = limit
|
|
31
|
+
super("session #{axis} token budget exceeded (#{used}/#{limit})")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Raised (internally) between runner steps when the worker is shutting
|
|
36
|
+
# down gracefully: the turn releases its claim and re-enqueues itself so
|
|
37
|
+
# the next worker resumes it promptly (the AJ Continuation checkpoint
|
|
38
|
+
# pattern). Not a failure — it never counts against the poison ladder.
|
|
39
|
+
class Interrupted < Error; end
|
|
40
|
+
end
|
data/lib/xeno/hooks.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# Observe-only event handlers from agent/hooks/*.rb:
|
|
3
|
+
#
|
|
4
|
+
# Xeno.hook "turn.completed" do |event|
|
|
5
|
+
# Metrics.increment("agent.turns", tags: [event.session.channel])
|
|
6
|
+
# end
|
|
7
|
+
#
|
|
8
|
+
# Xeno.hook "*" do |event| ... end # every event
|
|
9
|
+
#
|
|
10
|
+
# Semantics:
|
|
11
|
+
# - Handlers fire AFTER the event row is durably committed (after_commit),
|
|
12
|
+
# typed handlers first, then the wildcard. The block receives the
|
|
13
|
+
# Xeno::Event record (event_type, data, index, session).
|
|
14
|
+
# - Observe-only: return values are ignored; hooks cannot veto anything
|
|
15
|
+
# or inject model context. A raising handler is logged and skipped — a
|
|
16
|
+
# hook can never break the runtime or fail the turn.
|
|
17
|
+
# - AT-LEAST-ONCE: replayed steps re-emit new events with new indexes.
|
|
18
|
+
# Key once-per-step side effects on (turn_id, step) from the data; key
|
|
19
|
+
# stored content on (session_id, index).
|
|
20
|
+
module Hooks
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def dispatch(event, definition: Xeno.definition)
|
|
24
|
+
hooks = definition.respond_to?(:hooks) ? definition.hooks : {}
|
|
25
|
+
return if hooks.empty?
|
|
26
|
+
|
|
27
|
+
handlers = Array(hooks[event.event_type]) + Array(hooks["*"])
|
|
28
|
+
handlers.each do |handler|
|
|
29
|
+
handler.call(event)
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
Rails.logger.warn(
|
|
32
|
+
"xeno: hook for #{event.event_type} raised #{e.class}: #{e.message} — hooks are observe-only, continuing"
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/xeno/info.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# Renders the resolved agent for `rake xeno:info` — the discovery
|
|
3
|
+
# diagnostics surface.
|
|
4
|
+
module Info
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def render(definition = Xeno.definition)
|
|
8
|
+
lines = []
|
|
9
|
+
lines << "Agent: #{definition.name}"
|
|
10
|
+
lines << "Root: #{definition.root}"
|
|
11
|
+
lines << "Model: #{definition.config.resolved_model}#{format_options(definition.config.model_options)}"
|
|
12
|
+
lines << ""
|
|
13
|
+
|
|
14
|
+
dynamic_suffix = definition.dynamic_instructions? ? " + dynamic (instructions.rb, resolved per turn)" : ""
|
|
15
|
+
if definition.instructions
|
|
16
|
+
preview = definition.instructions.strip.lines.first&.strip
|
|
17
|
+
lines << "Instructions: instructions.md (#{definition.instructions.bytesize} bytes)#{dynamic_suffix} — #{preview}"
|
|
18
|
+
elsif definition.dynamic_instructions?
|
|
19
|
+
lines << "Instructions: dynamic only (instructions.rb, resolved per turn)"
|
|
20
|
+
else
|
|
21
|
+
lines << "Instructions: MISSING"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
lines << ""
|
|
25
|
+
lines << "Tools (#{definition.tools.size}):"
|
|
26
|
+
if definition.tools.any?
|
|
27
|
+
width = definition.tools.keys.map(&:length).max
|
|
28
|
+
definition.tools.each do |tool_name, klass|
|
|
29
|
+
lines << " #{tool_name.ljust(width)} #{klass.name} — #{klass.description || '(no description)'}"
|
|
30
|
+
end
|
|
31
|
+
else
|
|
32
|
+
lines << " (none — add agent/tools/*.rb)"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if definition.schedules.any?
|
|
36
|
+
lines << ""
|
|
37
|
+
lines << "Schedules (#{definition.schedules.size}):"
|
|
38
|
+
width = definition.schedules.keys.map(&:length).max
|
|
39
|
+
definition.schedules.each do |schedule_name, schedule|
|
|
40
|
+
lines << " #{schedule_name.ljust(width)} #{schedule.cron} — #{schedule.prompt.lines.first&.strip}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if definition.channels.any?
|
|
45
|
+
lines << ""
|
|
46
|
+
lines << "Channels (#{definition.channels.size} + http built-in):"
|
|
47
|
+
definition.channels.each_key do |channel_name|
|
|
48
|
+
lines << " #{channel_name}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if definition.respond_to?(:hooks) && definition.hooks.any?
|
|
53
|
+
lines << ""
|
|
54
|
+
lines << "Hooks (observe-only, at-least-once):"
|
|
55
|
+
definition.hooks.each do |event_type, handlers|
|
|
56
|
+
lines << " #{event_type} — #{handlers.size} handler#{'s' if handlers.size > 1}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if definition.diagnostics.any?
|
|
61
|
+
lines << ""
|
|
62
|
+
lines << "Diagnostics:"
|
|
63
|
+
definition.diagnostics.each do |diag|
|
|
64
|
+
lines << " [#{diag.level.upcase}] #{diag.message}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
lines.join("\n")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def format_options(options)
|
|
72
|
+
options.empty? ? "" : " #{options.inspect}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/xeno/inputs.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# Resolving parked work: approvals, denials, and question answers. Each
|
|
3
|
+
# resolution updates the action and — once no other action on the turn is
|
|
4
|
+
# still awaiting input — flips the turn back to pending and enqueues the
|
|
5
|
+
# resume job. A parked session holds zero compute; this is the only way
|
|
6
|
+
# back in.
|
|
7
|
+
module Inputs
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def approve!(action, principal: nil)
|
|
11
|
+
resolve(action) do
|
|
12
|
+
action.update!(status: "approved", resolved_at: Time.current, resolved_by: principal)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def deny!(action, reason: nil, principal: nil)
|
|
17
|
+
resolve(action) do
|
|
18
|
+
content = JSON.generate({ denied: true, reason: reason || "denied by user" })
|
|
19
|
+
action.update!(
|
|
20
|
+
status: "denied",
|
|
21
|
+
output: { "content" => content },
|
|
22
|
+
resolved_at: Time.current,
|
|
23
|
+
resolved_by: principal
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def answer!(action, answer, principal: nil)
|
|
29
|
+
raise ArgumentError, "not a question: #{action.tool_name}" unless action.kind == "question"
|
|
30
|
+
|
|
31
|
+
resolve(action) do
|
|
32
|
+
action.update!(
|
|
33
|
+
status: "completed",
|
|
34
|
+
output: { "content" => JSON.generate({ answer: answer }) },
|
|
35
|
+
resolved_at: Time.current,
|
|
36
|
+
resolved_by: principal
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Shared plumbing: guard the state, apply the resolution, resume when
|
|
42
|
+
# the turn has nothing else to wait for. Resolution, status flip, and
|
|
43
|
+
# the resume enqueue commit in ONE transaction: on a DB-backed queue
|
|
44
|
+
# (Solid Queue shares the app database, and ActiveJob 8.1 enqueues
|
|
45
|
+
# in-transaction by default) a crash can never separate "approved" from
|
|
46
|
+
# "job exists" — either everything landed or the input is still pending
|
|
47
|
+
# and the human just retries. Backends that defer or lose the enqueue
|
|
48
|
+
# fall back to the reaper's sweeps.
|
|
49
|
+
def resolve(action)
|
|
50
|
+
unless action.status == "pending_approval"
|
|
51
|
+
raise Xeno::Error, "action #{action.id} is not awaiting input (status: #{action.status})"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
ActiveRecord::Base.transaction do
|
|
55
|
+
yield
|
|
56
|
+
resume_turn(action.turn)
|
|
57
|
+
end
|
|
58
|
+
action
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Resuming with unanswered inputs would generate against partial tool
|
|
62
|
+
# results (an invalid provider state) — the runner would just re-park,
|
|
63
|
+
# so don't bother waking it until everything is resolved.
|
|
64
|
+
def resume_turn(turn)
|
|
65
|
+
return if turn.actions.where(status: "pending_approval").exists?
|
|
66
|
+
|
|
67
|
+
# Resumes are human-driven and unbounded — counted apart from the
|
|
68
|
+
# failure `attempts` so approvals can never poison the turn (H2).
|
|
69
|
+
resumed = Turn.where(id: turn.id, status: "waiting")
|
|
70
|
+
.update_all("status = 'pending', resumes = resumes + 1")
|
|
71
|
+
return unless resumed == 1
|
|
72
|
+
|
|
73
|
+
turn.session.update!(status: "running") if turn.session.status == "waiting"
|
|
74
|
+
turn.enqueue!
|
|
75
|
+
turn
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/xeno/reaper.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# The second half of the failure matrix: nothing else owns turns that
|
|
3
|
+
# fall out of the retry ladder. The reaper finds work that SHOULD be
|
|
4
|
+
# running but has no live owner and re-enqueues it. Duplicate-safe by
|
|
5
|
+
# construction — the claim CAS means a redundant TurnJob exits quietly,
|
|
6
|
+
# and a poisoned turn fails properly at claim time.
|
|
7
|
+
#
|
|
8
|
+
# Wiring: ReaperJob rides Solid Queue's recurring machinery (a managed
|
|
9
|
+
# entry written by Schedules.sync! in mounted mode, storage/recurring.yml
|
|
10
|
+
# in standalone mode). Other queue backends trigger it however they run
|
|
11
|
+
# periodic work: `Xeno::ReaperJob.perform_later` or `bin/rails xeno:reap`.
|
|
12
|
+
module Reaper
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# One sweep. Rescues, in order:
|
|
16
|
+
# 1. running turns with a stale heartbeat — the owner died mid-step
|
|
17
|
+
# (kill -9) or the queue exhausted its retries with the job in the
|
|
18
|
+
# failed set; claim! reclaims these directly.
|
|
19
|
+
# 2. stale pending turns — staged or released-for-retry, but no job
|
|
20
|
+
# ever came back (lost/discarded job).
|
|
21
|
+
# 3. waiting turns with nothing left to wait for — every action
|
|
22
|
+
# resolved but the resume enqueue never happened (crash between
|
|
23
|
+
# resolve and enqueue). Genuinely parked turns are never touched.
|
|
24
|
+
def sweep!(stale_before: Xeno.config.turn_stale_after.ago)
|
|
25
|
+
rescued = []
|
|
26
|
+
|
|
27
|
+
Turn.where(status: "running")
|
|
28
|
+
.where("heartbeat_at IS NULL OR heartbeat_at < ?", stale_before)
|
|
29
|
+
.find_each do |turn|
|
|
30
|
+
turn.enqueue!
|
|
31
|
+
rescued << turn
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Turn.where(status: "pending")
|
|
35
|
+
.where(updated_at: ...stale_before)
|
|
36
|
+
.find_each do |turn|
|
|
37
|
+
turn.enqueue!
|
|
38
|
+
rescued << turn
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Turn.where(status: "waiting")
|
|
42
|
+
.where(updated_at: ...stale_before)
|
|
43
|
+
.find_each do |turn|
|
|
44
|
+
next if turn.actions.where(status: "pending_approval").exists?
|
|
45
|
+
|
|
46
|
+
rescued << turn if Inputs.resume_turn(turn)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
rescued
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|