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,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,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
|