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,286 @@
1
+ module Xeno
2
+ # The resolved agent: everything discovered under the app's agent/
3
+ # directory, plus diagnostics for anything misplaced or broken.
4
+ # Discovery never raises — a broken agent directory yields a definition
5
+ # whose diagnostics say exactly what is wrong (surfaced by `rake xeno:info`).
6
+ class AgentDefinition
7
+ SLOT_DIRS = %w[tools skills channels schedules hooks lib].freeze
8
+ TOOL_SLUG = /\A[a-z][a-z0-9_]*\z/
9
+
10
+ Diagnostic = Struct.new(:level, :message) do
11
+ def error? = level == :error
12
+ end
13
+
14
+ Schedule = Struct.new(:name, :cron, :prompt, keyword_init: true)
15
+
16
+ # What agent/instructions.rb's block receives.
17
+ InstructionContext = Struct.new(:session, :principal, keyword_init: true)
18
+
19
+ attr_reader :root, :name, :config, :instructions, :dynamic_instructions,
20
+ :tools, :schedules, :channels, :hooks, :diagnostics
21
+
22
+ # Builds the definition for the app's agent root. `resolver` maps a tool
23
+ # file's camelized basename to its constant; the default asks Zeitwerk
24
+ # (via const_get on Xeno::Tools) and is only overridden in tests.
25
+ def self.load(root, name:, resolver: nil)
26
+ new(root: root, name: name, resolver: resolver).tap(&:discover)
27
+ end
28
+
29
+ def initialize(root:, name:, resolver: nil)
30
+ @root = Pathname(root)
31
+ @name = name
32
+ @resolver = resolver || method(:resolve_tool_constant)
33
+ @config = AgentConfig.new
34
+ @instructions = nil
35
+ @dynamic_instructions = nil
36
+ @tools = {}
37
+ @schedules = {}
38
+ @channels = {}
39
+ @hooks = {}
40
+ @diagnostics = []
41
+ end
42
+
43
+ def discover
44
+ unless root.directory?
45
+ error "no agent/ directory at #{root} — run `rails g xeno:install`"
46
+ return self
47
+ end
48
+
49
+ load_agent_config
50
+ load_instructions
51
+ discover_tools
52
+ discover_schedules
53
+ discover_channels
54
+ discover_hooks
55
+ report_misplaced_files
56
+ self
57
+ end
58
+
59
+ def tool_classes
60
+ tools.values
61
+ end
62
+
63
+ def dynamic_instructions? = !@dynamic_instructions.nil?
64
+
65
+ # The system prompt for one turn: static markdown first, the dynamic
66
+ # block's return appended (resolved fresh at every turn stage with the
67
+ # session context — the current_user use case). A raising block never
68
+ # bricks the session: the turn proceeds on the static instructions and
69
+ # the failure is logged.
70
+ def instructions_for(session: nil)
71
+ parts = [ instructions ]
72
+ if @dynamic_instructions
73
+ context = InstructionContext.new(session: session, principal: session&.principal)
74
+ begin
75
+ parts << @dynamic_instructions.call(context).to_s
76
+ rescue StandardError => e
77
+ Rails.logger.warn(
78
+ "xeno: agent/instructions.rb raised #{e.class}: #{e.message} — using static instructions only"
79
+ )
80
+ end
81
+ end
82
+ combined = parts.compact.map(&:strip).reject(&:empty?).join("\n\n")
83
+ combined.empty? ? nil : combined
84
+ end
85
+
86
+ def errors? = diagnostics.any?(&:error?)
87
+
88
+ private
89
+
90
+ def load_agent_config
91
+ file = root.join("agent.rb")
92
+ return unless file.file?
93
+
94
+ Xeno.capture_agent_config { load file.to_s }
95
+ captured = Xeno.captured_agent_config
96
+ if captured
97
+ @config = captured
98
+ else
99
+ warn_diag "agent.rb loaded but never called Xeno.agent — using defaults"
100
+ end
101
+ rescue StandardError, SyntaxError => e
102
+ error "agent.rb failed to load: #{e.class}: #{e.message}"
103
+ end
104
+
105
+ def load_instructions
106
+ file = root.join("instructions.md")
107
+ if file.file?
108
+ @instructions = file.read
109
+ error "instructions.md is empty" if @instructions.strip.empty?
110
+ end
111
+
112
+ load_dynamic_instructions
113
+
114
+ if @instructions.nil? && @dynamic_instructions.nil?
115
+ error "instructions are missing (instructions.md and/or instructions.rb — the agent's always-on system prompt)"
116
+ end
117
+ end
118
+
119
+ # agent/instructions.rb — `Xeno.instructions do |context| ... end`,
120
+ # resolved at turn-stage time (context: session, principal) and appended
121
+ # to the static markdown.
122
+ def load_dynamic_instructions
123
+ file = root.join("instructions.rb")
124
+ return unless file.file?
125
+
126
+ Xeno.capture_instructions { load file.to_s }
127
+ captured = Xeno.captured_instructions
128
+ if captured
129
+ @dynamic_instructions = captured
130
+ else
131
+ warn_diag "instructions.rb loaded but never called Xeno.instructions — ignored"
132
+ end
133
+ rescue StandardError, SyntaxError => e
134
+ error "instructions.rb failed to load: #{e.class}: #{e.message}"
135
+ end
136
+
137
+ def discover_tools
138
+ dir = root.join("tools")
139
+ return unless dir.directory?
140
+
141
+ dir.glob("*.rb").sort.each do |file|
142
+ slug = file.basename(".rb").to_s
143
+
144
+ unless slug.match?(TOOL_SLUG)
145
+ error "tools/#{file.basename}: invalid tool name #{slug.inspect} (want snake_case: get_weather.rb)"
146
+ next
147
+ end
148
+
149
+ klass = begin
150
+ @resolver.call(slug)
151
+ rescue NameError => e
152
+ error "tools/#{file.basename}: expected it to define Xeno::Tools::#{slug.camelize} (#{e.message})"
153
+ next
154
+ end
155
+
156
+ # Xeno::Tool required, not just RubyLLM::Tool: a plain RubyLLM
157
+ # subclass leaks a namespaced wire name (`xeno--tools--foo`) that
158
+ # misses the slug-keyed lookup, and it has no approval API — the
159
+ # gate would silently fail open.
160
+ unless klass.is_a?(Class) && klass < Xeno::Tool
161
+ error "tools/#{file.basename}: Xeno::Tools::#{slug.camelize} must subclass Xeno::Tool " \
162
+ "(naming and the approval gate depend on it)"
163
+ next
164
+ end
165
+
166
+ tools[slug] = klass
167
+ end
168
+ end
169
+
170
+ def resolve_tool_constant(slug)
171
+ Xeno::Tools.const_get(slug.camelize)
172
+ end
173
+
174
+ # agent/schedules/*.md — YAML frontmatter with a cron: line, body is the
175
+ # task prompt. Compiled to Solid Queue recurring entries by
176
+ # `rake xeno:schedules:sync`; never fired on cadence in development.
177
+ def discover_schedules
178
+ dir = root.join("schedules")
179
+ return unless dir.directory?
180
+
181
+ dir.glob("*.md").sort.each do |file|
182
+ slug = file.basename(".md").to_s
183
+
184
+ unless slug.match?(TOOL_SLUG)
185
+ error "schedules/#{file.basename}: invalid schedule name #{slug.inspect} (want snake_case)"
186
+ next
187
+ end
188
+
189
+ frontmatter, prompt = parse_frontmatter(file.read)
190
+ cron = frontmatter["cron"].presence
191
+
192
+ unless cron
193
+ error "schedules/#{file.basename}: missing `cron:` in the frontmatter"
194
+ next
195
+ end
196
+ if prompt.blank?
197
+ error "schedules/#{file.basename}: no prompt body after the frontmatter"
198
+ next
199
+ end
200
+
201
+ schedules[slug] = Schedule.new(name: slug, cron: cron, prompt: prompt)
202
+ end
203
+ end
204
+
205
+ # agent/channels/*.rb — DSL files (`Xeno.channel :slack do ... end`).
206
+ # Loading registers the channel globally; the definition records what
207
+ # each file declared for diagnostics and xeno:info.
208
+ def discover_channels
209
+ dir = root.join("channels")
210
+ return unless dir.directory?
211
+
212
+ dir.glob("*.rb").sort.each do |file|
213
+ slug = file.basename(".rb").to_s
214
+
215
+ channel = begin
216
+ load file.to_s
217
+ Xeno::Channels.registry[slug.to_sym]
218
+ rescue StandardError, SyntaxError => e
219
+ error "channels/#{file.basename}: failed to load: #{e.class}: #{e.message}"
220
+ next
221
+ end
222
+
223
+ if channel
224
+ channels[slug] = channel
225
+ else
226
+ error "channels/#{file.basename}: expected it to call Xeno.channel :#{slug}"
227
+ end
228
+ end
229
+ end
230
+
231
+ # agent/hooks/*.rb — observe-only handlers (`Xeno.hook "type" do … end`).
232
+ # One file may declare several; they merge across files. Handlers for
233
+ # unknown event types get a warning (typos never fire).
234
+ def discover_hooks
235
+ dir = root.join("hooks")
236
+ return unless dir.directory?
237
+
238
+ dir.glob("*.rb").sort.each do |file|
239
+ captured = begin
240
+ Xeno.capture_hooks { load file.to_s }
241
+ rescue StandardError, SyntaxError => e
242
+ error "hooks/#{file.basename}: failed to load: #{e.class}: #{e.message}"
243
+ next
244
+ end
245
+
246
+ if captured.empty?
247
+ warn_diag "hooks/#{file.basename}: loaded but never called Xeno.hook — ignored"
248
+ next
249
+ end
250
+
251
+ captured.each do |event_type, handlers|
252
+ unless event_type == "*" || Event::TYPES.include?(event_type)
253
+ warn_diag "hooks/#{file.basename}: #{event_type.inspect} is not in the event vocabulary (typo?) — it will never fire"
254
+ end
255
+ (hooks[event_type] ||= []).concat(handlers)
256
+ end
257
+ end
258
+ end
259
+
260
+ def parse_frontmatter(content)
261
+ if content =~ /\A---\s*\n(.*?)\n---\s*\n?(.*)\z/m
262
+ [ YAML.safe_load(Regexp.last_match(1)) || {}, Regexp.last_match(2).strip ]
263
+ else
264
+ [ {}, content.strip ]
265
+ end
266
+ rescue Psych::SyntaxError
267
+ [ {}, content.strip ]
268
+ end
269
+
270
+ def report_misplaced_files
271
+ root.glob("*.rb").each do |file|
272
+ next if %w[agent.rb instructions.rb].include?(file.basename.to_s)
273
+
274
+ warn_diag "#{file.basename}: ruby files at the agent root are not loaded — did you mean tools/#{file.basename}?"
275
+ end
276
+
277
+ root.children.select(&:directory?).each do |dir|
278
+ slot = dir.basename.to_s
279
+ warn_diag "#{slot}/: not a known slot (#{SLOT_DIRS.join(', ')})" unless SLOT_DIRS.include?(slot)
280
+ end
281
+ end
282
+
283
+ def error(message) = diagnostics << Diagnostic.new(:error, message)
284
+ def warn_diag(message) = diagnostics << Diagnostic.new(:warning, message)
285
+ end
286
+ end
@@ -0,0 +1,4 @@
1
+ module Xeno
2
+ # What an approval lambda gets to look at.
3
+ ApprovalContext = Struct.new(:session, :turn, :tool_name, :arguments, :principal, keyword_init: true)
4
+ end
@@ -0,0 +1,62 @@
1
+ module Xeno
2
+ # Models send JSON-typed arguments loosely — the first field run had
3
+ # llama sending "4200" (String) for an integer-typed parameter, which
4
+ # flowed into execute unchecked. Casts each argument per the tool's
5
+ # declared parameter type; anything uncoercible becomes a validation
6
+ # error the runner turns into an error tool result (the model retries
7
+ # with fixed arguments) — never an exception into execute.
8
+ module Arguments
9
+ module_function
10
+
11
+ # Returns [coerced_arguments, errors]. Undeclared keys pass through
12
+ # untouched (schema-based tools declare nothing here).
13
+ def coerce(tool_class, arguments)
14
+ declared = tool_class.respond_to?(:declared_parameters) ? tool_class.declared_parameters : {}
15
+ return [ arguments, [] ] if declared.empty? || !arguments.is_a?(Hash)
16
+
17
+ coerced = {}
18
+ errors = []
19
+ arguments.each do |key, value|
20
+ parameter = declared[key.to_sym]
21
+ if parameter.nil? || value.nil?
22
+ coerced[key] = value
23
+ next
24
+ end
25
+
26
+ begin
27
+ coerced[key] = cast(value, parameter.type.to_s)
28
+ rescue ArgumentError, TypeError
29
+ errors << "#{key}: expected #{parameter.type}, got #{value.inspect}"
30
+ end
31
+ end
32
+ [ coerced, errors ]
33
+ end
34
+
35
+ def cast(value, type)
36
+ case type
37
+ when "integer" then value.is_a?(Integer) ? value : Integer(value, exception: true)
38
+ when "number" then value.is_a?(Numeric) ? value : Float(value)
39
+ when "boolean" then boolean(value)
40
+ when "string" then value.is_a?(String) ? value : scalar_to_string(value)
41
+ when "array" then value.is_a?(Array) ? value : raise(TypeError)
42
+ when "object" then value.is_a?(Hash) ? value : raise(TypeError)
43
+ else value
44
+ end
45
+ end
46
+
47
+ def boolean(value)
48
+ case value
49
+ when true, false then value
50
+ when "true", "1", 1 then true
51
+ when "false", "0", 0 then false
52
+ else raise TypeError
53
+ end
54
+ end
55
+
56
+ def scalar_to_string(value)
57
+ raise TypeError if value.is_a?(Array) || value.is_a?(Hash)
58
+
59
+ value.to_s
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,18 @@
1
+ module Xeno
2
+ # The one framework tool in v0.1. It has no executable body: the model
3
+ # calling it parks the turn with an input.requested event, and a human's
4
+ # answer is injected as the tool result on resume — the same mechanics as
5
+ # approvals. Registered on every chat (outside the Xeno::Tools namespace,
6
+ # which belongs to the app's agent/tools/).
7
+ class AskQuestion < Tool
8
+ description "Ask the human a question and wait for their answer. " \
9
+ "Use when you need information or a decision only they can provide."
10
+ parameter :question, description: "The question to ask"
11
+ parameter :choices, type: :array, required: false,
12
+ description: "Optional list of suggested answers"
13
+
14
+ def execute(question:, choices: nil)
15
+ raise "ask_question is never executed — the runtime parks the turn instead"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,311 @@
1
+ require "openssl"
2
+ require "net/http"
3
+
4
+ module Xeno
5
+ module Channels
6
+ # The Slack channel: Events API webhook with constant-time signature
7
+ # verification, thread-scoped sessions (continuation token =
8
+ # "slack:<channel>:<thread_ts>"), replies posted at turn completion,
9
+ # and plain-text approvals ("approve"/"deny"/an answer) in the thread.
10
+ #
11
+ # v0.1 scope: mentions and DMs start sessions; thread replies continue
12
+ # them. Post-then-edit streaming and Block Kit buttons are v0.2.
13
+ class Slack
14
+ SIGNATURE_VERSION = "v0".freeze
15
+ TIMESTAMP_TOLERANCE = 300 # seconds; replayed webhooks are rejected
16
+
17
+ APPROVE_WORDS = %w[approve approved yes y ok 👍].freeze
18
+ DENY_WORDS = %w[deny denied no n reject rejected 👎].freeze
19
+
20
+ def initialize(&block)
21
+ @api_base = "https://slack.com/api"
22
+ instance_eval(&block) if block
23
+ end
24
+
25
+ # --- the config DSL (setter-and-reader hybrids, like AgentConfig) ---
26
+
27
+ def signing_secret(value = nil)
28
+ @signing_secret = value if value
29
+ @signing_secret
30
+ end
31
+
32
+ def bot_token(value = nil)
33
+ @bot_token = value if value
34
+ @bot_token
35
+ end
36
+
37
+ # Overridable for offline tests (the fake Slack API).
38
+ def api_base(value = nil)
39
+ @api_base = value if value
40
+ @api_base
41
+ end
42
+
43
+ # Opt-in post-then-edit streaming: the reply posts on the first model
44
+ # delta and is edited (~1s cadence, rate-limit aware) until the final
45
+ # text lands. Off by default — replies post once at completion.
46
+ def stream_replies(value = nil)
47
+ @stream_replies = value unless value.nil?
48
+ @stream_replies
49
+ end
50
+
51
+ # --- inbound: webhook verification + event handling ---
52
+
53
+ def verify_signature(timestamp, signature, raw_body)
54
+ return false if signing_secret.blank? || timestamp.blank? || signature.blank?
55
+ return false if (Time.now.to_i - timestamp.to_i).abs > TIMESTAMP_TOLERANCE
56
+
57
+ base = "#{SIGNATURE_VERSION}:#{timestamp}:#{raw_body}"
58
+ digest = OpenSSL::HMAC.hexdigest("sha256", signing_secret, base)
59
+ expected = "#{SIGNATURE_VERSION}=#{digest}"
60
+ ActiveSupport::SecurityUtils.secure_compare(expected, signature.to_s)
61
+ end
62
+
63
+ def handle_event(payload)
64
+ event = payload["event"] || {}
65
+ return if event["bot_id"].present? # never talk to ourselves
66
+
67
+ case event["type"]
68
+ when "app_mention"
69
+ handle_message(event)
70
+ when "message"
71
+ # DMs only; edits/joins/etc. carry a subtype and are ignored.
72
+ handle_message(event) if event["channel_type"] == "im" && event["subtype"].blank?
73
+ end
74
+ end
75
+
76
+ # --- outbound: delivery ---
77
+
78
+ def deliver_completion(session, content)
79
+ channel_id, thread_ts = thread_for(session)
80
+ return unless channel_id
81
+
82
+ post_message(channel: channel_id, thread_ts: thread_ts, text: content.to_s)
83
+ end
84
+
85
+ # One per content-bearing model reply when stream_replies is on: the
86
+ # runner pushes deltas, we post-then-edit in the thread.
87
+ def streamer_for(session)
88
+ return nil unless stream_replies
89
+
90
+ channel_id, thread_ts = thread_for(session)
91
+ return nil unless channel_id
92
+
93
+ Streamer.new(self, channel: channel_id, thread_ts: thread_ts)
94
+ end
95
+
96
+ def deliver_input_request(session, actions)
97
+ channel_id, thread_ts = thread_for(session)
98
+ return unless channel_id
99
+
100
+ prompts = actions.map do |action|
101
+ if action.kind == "question"
102
+ question = action.input&.dig("question")
103
+ choices = Array(action.input&.dig("choices"))
104
+ choices.any? ? "❓ #{question} (#{choices.join(' / ')})" : "❓ #{question}"
105
+ else
106
+ "⏸ Approval needed: `#{action.tool_name}(#{action.input.to_json})` — reply *approve* or *deny*."
107
+ end
108
+ end
109
+ post_message(channel: channel_id, thread_ts: thread_ts, text: prompts.join("\n"))
110
+ end
111
+
112
+ # Raised by api_post on HTTP 429; carries Slack's Retry-After.
113
+ class RateLimited < Xeno::Error
114
+ attr_reader :retry_after
115
+
116
+ def initialize(retry_after:)
117
+ @retry_after = [ retry_after.to_f, 1.0 ].max
118
+ super("slack rate limited (retry after #{@retry_after}s)")
119
+ end
120
+ end
121
+
122
+ # Post-then-edit delivery for ONE streaming model reply. The first
123
+ # content delta posts the thread message; later deltas edit it at most
124
+ # once per EDIT_INTERVAL; finish writes the durable final text. Rate
125
+ # limits defer the next edit (Retry-After); any hard failure turns
126
+ # streaming off for this reply — the completion post is the fallback,
127
+ # and the durable truth is rows either way.
128
+ class Streamer
129
+ EDIT_INTERVAL = 1.0 # seconds — comfortably under chat.update's tier
130
+
131
+ def initialize(slack, channel:, thread_ts:)
132
+ @slack = slack
133
+ @channel = channel
134
+ @thread_ts = thread_ts
135
+ @buffer = +""
136
+ @ts = nil
137
+ @posted_text = nil
138
+ @next_edit_at = 0.0
139
+ @dead = false
140
+ end
141
+
142
+ def push(chunk)
143
+ content = chunk.respond_to?(:content) ? chunk.content : nil
144
+ return if @dead || content.to_s.empty?
145
+
146
+ @buffer << content
147
+ @ts.nil? ? start_message : edit_message(@buffer.dup)
148
+ end
149
+
150
+ # Returns true when this streamer delivered the reply (the runner
151
+ # then skips the completion post); false hands delivery back.
152
+ def finish(final_text)
153
+ return false if @ts.nil?
154
+
155
+ text = final_text.to_s
156
+ return true if @posted_text == text
157
+
158
+ wait = @next_edit_at - now
159
+ sleep([ wait, 3.0 ].min) if wait.positive? # respect a pending rate-limit window
160
+ @slack.api_post("chat.update", channel: @channel, ts: @ts, text: text)
161
+ @posted_text = text
162
+ true
163
+ rescue StandardError => e
164
+ Rails.logger.warn("xeno: slack streaming final edit failed: #{e.class}: #{e.message}")
165
+ false
166
+ end
167
+
168
+ private
169
+
170
+ def start_message
171
+ payload = @slack.api_post("chat.postMessage",
172
+ channel: @channel, thread_ts: @thread_ts, text: @buffer.dup)
173
+ @ts = payload["ts"]
174
+ @posted_text = @buffer.dup
175
+ @next_edit_at = now + EDIT_INTERVAL
176
+ rescue StandardError => e
177
+ @dead = true
178
+ Rails.logger.warn("xeno: slack streaming post failed: #{e.class}: #{e.message}")
179
+ end
180
+
181
+ def edit_message(text)
182
+ return if now < @next_edit_at
183
+
184
+ @slack.api_post("chat.update", channel: @channel, ts: @ts, text: text)
185
+ @posted_text = text
186
+ @next_edit_at = now + EDIT_INTERVAL
187
+ rescue RateLimited => e
188
+ @next_edit_at = now + e.retry_after
189
+ rescue StandardError => e
190
+ @dead = true
191
+ Rails.logger.warn("xeno: slack streaming edit failed: #{e.class}: #{e.message}")
192
+ end
193
+
194
+ def now
195
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
196
+ end
197
+ end
198
+
199
+ # One JSON POST to the Slack Web API; 429 becomes RateLimited (with
200
+ # Retry-After), any other failure raises Xeno::Error.
201
+ def api_post(method, payload)
202
+ uri = URI("#{api_base}/#{method}")
203
+ request = Net::HTTP::Post.new(uri)
204
+ request["Authorization"] = "Bearer #{bot_token}"
205
+ request["Content-Type"] = "application/json; charset=utf-8"
206
+ request.body = JSON.generate(payload)
207
+
208
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 10) do |http|
209
+ http.request(request)
210
+ end
211
+ parsed = JSON.parse(response.body) rescue {}
212
+
213
+ raise RateLimited.new(retry_after: response["Retry-After"]) if response.code.to_i == 429
214
+ unless response.is_a?(Net::HTTPSuccess) && parsed["ok"]
215
+ raise Xeno::Error, "slack #{method} failed: #{response.code} #{parsed['error']}"
216
+ end
217
+
218
+ parsed
219
+ end
220
+
221
+ private
222
+
223
+ def handle_message(event)
224
+ text = strip_mention(event["text"].to_s).strip
225
+ return if text.empty?
226
+
227
+ channel_id = event["channel"]
228
+ thread_ts = event["thread_ts"] || event["ts"]
229
+ token = "slack:#{channel_id}:#{thread_ts}"
230
+ principal = {
231
+ "type" => "slack",
232
+ "user_id" => event["user"],
233
+ "team_id" => event["team"],
234
+ "channel_id" => channel_id
235
+ }
236
+
237
+ session = Session.find_by(continuation_token: token)
238
+ if session&.active?
239
+ continue_session(session, text, principal)
240
+ else
241
+ Session.start!(message: text, channel: "slack", principal: principal, continuation_token: token)
242
+ end
243
+ end
244
+
245
+ # A reply into a waiting thread resolves the FIRST pending input; an
246
+ # unrelated reply is HELD as the next message (never auto-deny, never
247
+ # a forced answer):
248
+ # - approvals: approve/deny words resolve; anything else holds.
249
+ # - questions with choices: match by 1-based index ("2"), by label
250
+ # (case-insensitive), or by exact option text; no match holds and
251
+ # the question stays pending.
252
+ # - free-form questions (no choices): any text is the answer.
253
+ def continue_session(session, text, principal)
254
+ pending = Action.joins(:turn)
255
+ .where(xeno_turns: { session_id: session.id }, status: "pending_approval")
256
+ .order(:id)
257
+ .first
258
+
259
+ if pending && session.status == "waiting"
260
+ if pending.kind == "question"
261
+ answer = match_answer(pending, text)
262
+ if answer
263
+ Inputs.answer!(pending, answer, principal: principal)
264
+ else
265
+ session.receive_message!(text) # held; the question stays pending
266
+ end
267
+ elsif APPROVE_WORDS.include?(text.downcase)
268
+ Inputs.approve!(pending, principal: principal)
269
+ elsif DENY_WORDS.include?(text.downcase)
270
+ Inputs.deny!(pending, principal: principal)
271
+ else
272
+ session.receive_message!(text) # held; the approval stays pending
273
+ end
274
+ else
275
+ session.receive_message!(text)
276
+ end
277
+ end
278
+
279
+ # The resolved answer for a question, or nil to hold the reply.
280
+ def match_answer(action, text)
281
+ choices = Array(action.input&.dig("choices")).map(&:to_s)
282
+ return text if choices.empty? # free-form: any text answers
283
+
284
+ normalized = text.strip
285
+ if normalized.match?(/\A\d+\z/)
286
+ index = normalized.to_i
287
+ return choices[index - 1] if index.between?(1, choices.size)
288
+ end
289
+
290
+ choices.find { |choice| choice.casecmp?(normalized) }
291
+ end
292
+
293
+ def strip_mention(text)
294
+ text.gsub(/<@[A-Z0-9]+>/, " ")
295
+ end
296
+
297
+ def thread_for(session)
298
+ token = session.continuation_token ||
299
+ session.metadata&.dig("released_continuation_token")
300
+ return nil unless token&.start_with?("slack:")
301
+
302
+ _prefix, channel_id, thread_ts = token.split(":", 3)
303
+ [ channel_id, thread_ts ]
304
+ end
305
+
306
+ def post_message(channel:, thread_ts:, text:)
307
+ api_post("chat.postMessage", channel: channel, thread_ts: thread_ts, text: text)
308
+ end
309
+ end
310
+ end
311
+ end