xeno 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +38 -0
  3. data/LICENSE +21 -0
  4. data/README.md +211 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/xeno/application.css +15 -0
  7. data/app/controllers/xeno/api_controller.rb +68 -0
  8. data/app/controllers/xeno/application_controller.rb +4 -0
  9. data/app/controllers/xeno/dev_controller.rb +24 -0
  10. data/app/controllers/xeno/dev_ui_controller.rb +71 -0
  11. data/app/controllers/xeno/health_controller.rb +10 -0
  12. data/app/controllers/xeno/sessions_controller.rb +131 -0
  13. data/app/controllers/xeno/slack_controller.rb +48 -0
  14. data/app/controllers/xeno/streams_controller.rb +122 -0
  15. data/app/helpers/xeno/application_helper.rb +4 -0
  16. data/app/jobs/xeno/application_job.rb +4 -0
  17. data/app/jobs/xeno/reaper_job.rb +12 -0
  18. data/app/jobs/xeno/schedule_job.rb +56 -0
  19. data/app/jobs/xeno/slack_event_job.rb +20 -0
  20. data/app/jobs/xeno/turn_job.rb +16 -0
  21. data/app/mailers/xeno/application_mailer.rb +6 -0
  22. data/app/models/xeno/action.rb +26 -0
  23. data/app/models/xeno/application_record.rb +5 -0
  24. data/app/models/xeno/chat.rb +22 -0
  25. data/app/models/xeno/dedup.rb +24 -0
  26. data/app/models/xeno/event.rb +63 -0
  27. data/app/models/xeno/message.rb +5 -0
  28. data/app/models/xeno/pending_message.rb +7 -0
  29. data/app/models/xeno/session.rb +231 -0
  30. data/app/models/xeno/turn.rb +125 -0
  31. data/app/views/layouts/xeno/application.html.erb +18 -0
  32. data/app/views/xeno/dev_ui/_styles.html.erb +24 -0
  33. data/app/views/xeno/dev_ui/index.html.erb +28 -0
  34. data/app/views/xeno/dev_ui/show.html.erb +115 -0
  35. data/config/routes.rb +25 -0
  36. data/db/migrate/20260804000001_create_xeno_llm_tables.rb +70 -0
  37. data/db/migrate/20260804000002_create_xeno_orchestration_tables.rb +70 -0
  38. data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +8 -0
  39. data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +8 -0
  40. data/db/migrate/20260805000003_create_xeno_dedups.rb +14 -0
  41. data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +9 -0
  42. data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +8 -0
  43. data/db/migrate/20260806000001_move_transcript_support_tables_to_ruby_llm.rb +133 -0
  44. data/docs/runtime.md +275 -0
  45. data/exe/xeno +133 -0
  46. data/lib/generators/xeno/install/install_generator.rb +51 -0
  47. data/lib/generators/xeno/install/templates/agent.rb +4 -0
  48. data/lib/generators/xeno/install/templates/initializer.rb +20 -0
  49. data/lib/generators/xeno/install/templates/instructions.md +6 -0
  50. data/lib/generators/xeno/tool/templates/tool.rb.tt +16 -0
  51. data/lib/generators/xeno/tool/tool_generator.rb +13 -0
  52. data/lib/tasks/xeno_tasks.rake +24 -0
  53. data/lib/xeno/agent_config.rb +66 -0
  54. data/lib/xeno/agent_definition.rb +286 -0
  55. data/lib/xeno/approval_context.rb +4 -0
  56. data/lib/xeno/arguments.rb +62 -0
  57. data/lib/xeno/ask_question.rb +18 -0
  58. data/lib/xeno/channels/slack.rb +311 -0
  59. data/lib/xeno/channels.rb +68 -0
  60. data/lib/xeno/compaction.rb +165 -0
  61. data/lib/xeno/configuration.rb +118 -0
  62. data/lib/xeno/engine.rb +29 -0
  63. data/lib/xeno/errors.rb +40 -0
  64. data/lib/xeno/hooks.rb +37 -0
  65. data/lib/xeno/info.rb +75 -0
  66. data/lib/xeno/inputs.rb +78 -0
  67. data/lib/xeno/reaper.rb +52 -0
  68. data/lib/xeno/schedules.rb +49 -0
  69. data/lib/xeno/session_state.rb +57 -0
  70. data/lib/xeno/standalone/local_secret.rb +26 -0
  71. data/lib/xeno/standalone/model_refresh.rb +26 -0
  72. data/lib/xeno/standalone/puma.rb +17 -0
  73. data/lib/xeno/standalone.rb +136 -0
  74. data/lib/xeno/tool.rb +73 -0
  75. data/lib/xeno/turn_runner.rb +545 -0
  76. data/lib/xeno/version.rb +3 -0
  77. data/lib/xeno.rb +117 -0
  78. metadata +151 -0
@@ -0,0 +1,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
@@ -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
@@ -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
@@ -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
@@ -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