xeno 0.0.1 → 0.0.2
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 +4 -4
- data/CHANGELOG.md +45 -2
- data/README.md +3 -1
- data/Rakefile +2 -0
- data/app/controllers/xeno/api_controller.rb +15 -18
- data/app/controllers/xeno/application_controller.rb +2 -0
- data/app/controllers/xeno/dev_controller.rb +5 -4
- data/app/controllers/xeno/dev_ui_controller.rb +5 -4
- data/app/controllers/xeno/health_controller.rb +3 -1
- data/app/controllers/xeno/sessions_controller.rb +22 -24
- data/app/controllers/xeno/slack_controller.rb +9 -10
- data/app/controllers/xeno/streams_controller.rb +18 -22
- data/app/helpers/xeno/application_helper.rb +2 -0
- data/app/jobs/xeno/application_job.rb +2 -0
- data/app/jobs/xeno/reaper_job.rb +5 -4
- data/app/jobs/xeno/schedule_job.rb +10 -13
- data/app/jobs/xeno/slack_event_job.rb +7 -7
- data/app/jobs/xeno/turn_job.rb +5 -4
- data/app/mailers/xeno/application_mailer.rb +2 -0
- data/app/models/xeno/action.rb +7 -12
- data/app/models/xeno/application_record.rb +2 -0
- data/app/models/xeno/chat.rb +9 -11
- data/app/models/xeno/dedup.rb +7 -7
- data/app/models/xeno/event.rb +16 -20
- data/app/models/xeno/message.rb +2 -0
- data/app/models/xeno/pending_message.rb +4 -2
- data/app/models/xeno/session.rb +52 -67
- data/app/models/xeno/turn.rb +29 -34
- data/config/routes.rb +2 -0
- data/db/migrate/{20260806000001_move_transcript_support_tables_to_ruby_llm.rb → 20260808000001_create_ruby_llm_tables.rb} +25 -54
- data/db/migrate/{20260804000002_create_xeno_orchestration_tables.rb → 20260808000002_create_xeno_tables.rb} +44 -14
- data/docs/configuration.md +56 -0
- data/docs/runtime.md +4 -4
- data/exe/xeno +1 -1
- data/lib/generators/xeno/install/install_generator.rb +3 -2
- data/lib/generators/xeno/install/templates/agent.rb +1 -2
- data/lib/generators/xeno/install/templates/initializer.rb +6 -9
- data/lib/generators/xeno/tool/tool_generator.rb +4 -2
- data/lib/tasks/xeno_tasks.rake +2 -0
- data/lib/xeno/agent_config.rb +9 -8
- data/lib/xeno/agent_definition.rb +25 -28
- data/lib/xeno/approval_context.rb +2 -0
- data/lib/xeno/arguments.rb +8 -9
- data/lib/xeno/ask_question.rb +5 -5
- data/lib/xeno/channels/slack.rb +25 -29
- data/lib/xeno/channels.rb +10 -9
- data/lib/xeno/compaction.rb +20 -33
- data/lib/xeno/configuration.rb +35 -44
- data/lib/xeno/engine.rb +8 -7
- data/lib/xeno/errors.rb +11 -10
- data/lib/xeno/hooks.rb +7 -10
- data/lib/xeno/info.rb +3 -2
- data/lib/xeno/inputs.rb +26 -19
- data/lib/xeno/reaper.rb +13 -17
- data/lib/xeno/schedules.rb +8 -9
- data/lib/xeno/session_state.rb +8 -9
- data/lib/xeno/standalone/local_secret.rb +7 -6
- data/lib/xeno/standalone/model_refresh.rb +6 -6
- data/lib/xeno/standalone/puma.rb +9 -9
- data/lib/xeno/standalone.rb +22 -21
- data/lib/xeno/tool.rb +28 -25
- data/lib/xeno/turn_runner.rb +74 -96
- data/lib/xeno/version.rb +3 -1
- data/lib/xeno.rb +16 -14
- metadata +4 -9
- data/db/migrate/20260804000001_create_xeno_llm_tables.rb +0 -70
- data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +0 -8
- data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +0 -8
- data/db/migrate/20260805000003_create_xeno_dedups.rb +0 -14
- data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +0 -9
- data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +0 -8
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Configuration reference
|
|
2
|
+
|
|
3
|
+
Every knob on `Xeno.configure`. Standalone apps configure in `agent/agent.rb`;
|
|
4
|
+
mounted apps use `config/initializers/xeno.rb`. The mechanics behind these knobs
|
|
5
|
+
— claims, heartbeats, budget and compaction semantics — are documented in
|
|
6
|
+
[runtime.md](runtime.md).
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
Xeno.configure do |config|
|
|
10
|
+
config.max_steps = 20
|
|
11
|
+
config.turn_stale_after = 5.minutes
|
|
12
|
+
end
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## The turn loop
|
|
16
|
+
|
|
17
|
+
| Setting | Default | Meaning |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `max_steps` | `20` | Per-turn model-call budget. |
|
|
20
|
+
| `max_turn_attempts` | `5` | Attempts before a turn is declared poison and fails. |
|
|
21
|
+
| `turn_stale_after` | `5.minutes` | A running turn with an older heartbeat is presumed dead and reclaimable. |
|
|
22
|
+
| `heartbeat_interval` | a quarter of `turn_stale_after` | Heartbeat cadence during model calls and tool bodies. |
|
|
23
|
+
|
|
24
|
+
## Budgets
|
|
25
|
+
|
|
26
|
+
| Setting | Default | Meaning |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `max_input_tokens_per_session` | `nil` (unlimited) | Per-session input-token budget. |
|
|
29
|
+
| `max_output_tokens_per_session` | `nil` (unlimited) | Per-session output-token budget. |
|
|
30
|
+
|
|
31
|
+
Per-agent override in `agent.rb`: `limits input_tokens:, output_tokens:`
|
|
32
|
+
(`false` disables an axis).
|
|
33
|
+
|
|
34
|
+
## Compaction
|
|
35
|
+
|
|
36
|
+
| Setting | Default | Meaning |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `compaction_threshold` | `0.9` | Fraction of the context window that triggers automatic compaction. `nil`/`false` disables it; manual stays available. |
|
|
39
|
+
| `compaction_context_window` | `nil` | Fallback window (tokens) for models the registry doesn't know. `nil`: automatic compaction never triggers for them. |
|
|
40
|
+
| `compaction_tail_turns` | `2` | Recent turns kept verbatim through a compaction. |
|
|
41
|
+
|
|
42
|
+
## HTTP and streaming
|
|
43
|
+
|
|
44
|
+
| Setting | Default | Meaning |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `authenticate` | `nil` | Fail-closed auth lambda: falsy return is a 401, truthy becomes the request principal. |
|
|
47
|
+
| `stream_poll_interval` | `0.25` | Seconds between event-stream polls. |
|
|
48
|
+
| `stream_catch_up_batch` | `500` | Max events per poll; bounds catch-up reads on long sessions. |
|
|
49
|
+
| `stream_max_duration` | `nil` | Hard cap (seconds) on one stream connection. `nil`: until the session ends. |
|
|
50
|
+
| `stream_shutdown_check` | `nil` | Lambda: "is the server shutting down?" — open streams close when true. `nil` detects Puma's graceful stop. |
|
|
51
|
+
|
|
52
|
+
## Deploys
|
|
53
|
+
|
|
54
|
+
| Setting | Default | Meaning |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `stopping_check` | `nil` | Lambda: "is the worker shutting down?" — probed between steps; a true return releases the claim and re-enqueues. `nil` asks the queue adapter. |
|
data/docs/runtime.md
CHANGED
|
@@ -9,9 +9,9 @@ how the durability machinery actually works. The README sells it; this explains
|
|
|
9
9
|
`running | waiting | completed | failed`. Owns one transcript chat, the turn ledger,
|
|
10
10
|
the event stream, and at most one `continuation_token` (channel-owned resume handle,
|
|
11
11
|
unique among ACTIVE sessions). A session stays active indefinitely — turns finishing
|
|
12
|
-
never retire it; **`reset` is the only terminal transition
|
|
12
|
+
never retire it; **`reset` is the only terminal transition**, and it nulls
|
|
13
13
|
the token (audit copy in `metadata`) so the same handle can start fresh. The
|
|
14
|
-
`failed` status and `session.failed` event are reserved (never produced
|
|
14
|
+
`failed` status and `session.failed` event are reserved (never produced today).
|
|
15
15
|
- **Turn** (`xeno_turns`) — one user message (or several, when queued messages fold)
|
|
16
16
|
and all work until the agent responds. One ActiveJob. Statuses:
|
|
17
17
|
`pending | running | waiting | completed | failed | cancelled`.
|
|
@@ -37,8 +37,8 @@ claim! (CAS) → clean crash artifacts → loop:
|
|
|
37
37
|
|
|
38
38
|
Rules inherited from the RubyLLM v2 investigation (each empirically verified):
|
|
39
39
|
|
|
40
|
-
1. Never `complete` (
|
|
41
|
-
runner IS the loop.
|
|
40
|
+
1. Never `complete` (a turn must bound its model calls) or `run_tools` (no approval
|
|
41
|
+
gate, no per-call action checkpoint). The runner IS the loop.
|
|
42
42
|
2. A killed process leaves a blank assistant row (`before_message` persists it before
|
|
43
43
|
the HTTP call) that makes `complete?` read true — trailing blank assistant rows are
|
|
44
44
|
deleted on entry.
|
data/exe/xeno
CHANGED
|
@@ -28,7 +28,7 @@ when "new"
|
|
|
28
28
|
gem "xeno"
|
|
29
29
|
# RubyLLM v2 is unreleased and the published 1.16.0 gem is incompatible;
|
|
30
30
|
# xeno is built against this exact commit. Drop the pin at RubyLLM v2 GA.
|
|
31
|
-
gem "ruby_llm", github: "crmne/ruby_llm", ref: "
|
|
31
|
+
gem "ruby_llm", github: "crmne/ruby_llm", ref: "be80b6f1"
|
|
32
32
|
# Loads .env at boot. Remove if you manage secrets another way.
|
|
33
33
|
gem "dotenv"
|
|
34
34
|
gem "puma"
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
4
|
module Generators
|
|
3
|
-
# Mounted-mode install: initializer, engine mount, migrations, and the
|
|
4
|
-
# agent/ scaffold.
|
|
5
|
+
# Mounted-mode install: initializer, engine mount, migrations, and the agent/ scaffold.
|
|
5
6
|
class InstallGenerator < Rails::Generators::Base
|
|
6
7
|
source_root File.expand_path("templates", __dir__)
|
|
7
8
|
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
Xeno.configure do |config|
|
|
2
|
-
# HTTP auth is FAIL-CLOSED: every endpoint (except health) returns 401
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# becomes the session's principal (visible to tools and approval lambdas).
|
|
2
|
+
# HTTP auth is FAIL-CLOSED: every endpoint (except health) returns 401 until you set this. The
|
|
3
|
+
# lambda receives the request; return something falsy to reject, or a truthy value to accept —
|
|
4
|
+
# whatever you return becomes the session's principal (visible to tools and approval lambdas).
|
|
6
5
|
#
|
|
7
6
|
# config.authenticate = ->(request) do
|
|
8
7
|
# token = request.headers["Authorization"]&.delete_prefix("Bearer ")
|
|
@@ -11,10 +10,8 @@ Xeno.configure do |config|
|
|
|
11
10
|
# )
|
|
12
11
|
# end
|
|
13
12
|
|
|
14
|
-
# Per-turn model-call budget (runaway protection).
|
|
15
|
-
# config.max_steps = 20
|
|
13
|
+
# Per-turn model-call budget (runaway protection). config.max_steps = 20
|
|
16
14
|
|
|
17
|
-
# How long a silent turn is presumed alive before another worker may
|
|
18
|
-
#
|
|
19
|
-
# config.turn_stale_after = 5.minutes
|
|
15
|
+
# How long a silent turn is presumed alive before another worker may reclaim it. Keep it above
|
|
16
|
+
# your slowest model call. config.turn_stale_after = 5.minutes
|
|
20
17
|
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
4
|
module Generators
|
|
3
|
-
# rails g xeno:tool GetWeather → agent/tools/get_weather.rb
|
|
4
|
-
#
|
|
5
|
+
# rails g xeno:tool GetWeather → agent/tools/get_weather.rb (the path supplies the runtime name:
|
|
6
|
+
# get_weather)
|
|
5
7
|
class ToolGenerator < Rails::Generators::NamedBase
|
|
6
8
|
source_root File.expand_path("templates", __dir__)
|
|
7
9
|
|
data/lib/tasks/xeno_tasks.rake
CHANGED
data/lib/xeno/agent_config.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# The config DSL evaluated from agent/agent.rb. Optional — defaults apply
|
|
3
|
-
# when the file is absent.
|
|
4
|
+
# The config DSL evaluated from agent/agent.rb. Optional — defaults apply when the file is absent.
|
|
4
5
|
#
|
|
5
6
|
# Xeno.agent do
|
|
6
7
|
# model "anthropic/claude-sonnet-5"
|
|
@@ -25,8 +26,8 @@ module Xeno
|
|
|
25
26
|
# limits input_tokens: 2_000_000, output_tokens: 200_000
|
|
26
27
|
# end
|
|
27
28
|
#
|
|
28
|
-
# `false` disables an axis even when a global cap is set; an axis left
|
|
29
|
-
#
|
|
29
|
+
# `false` disables an axis even when a global cap is set; an axis left out inherits the global
|
|
30
|
+
# `Xeno.config.max_*_tokens_per_session`.
|
|
30
31
|
def limits(**caps)
|
|
31
32
|
unless caps.empty?
|
|
32
33
|
unknown = caps.keys - %i[input_tokens output_tokens]
|
|
@@ -37,16 +38,16 @@ module Xeno
|
|
|
37
38
|
@limits
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
# The effective cap for one axis: agent override first (false = off),
|
|
41
|
-
#
|
|
41
|
+
# The effective cap for one axis: agent override first (false = off), then the global config.
|
|
42
|
+
# nil = unlimited.
|
|
42
43
|
def token_limit(axis, global)
|
|
43
44
|
return nil if @limits[axis] == false
|
|
44
45
|
|
|
45
46
|
@limits.fetch(axis, nil) || global
|
|
46
47
|
end
|
|
47
48
|
|
|
48
|
-
# DSL setter and reader in one: `model "id", **options` inside the block,
|
|
49
|
-
#
|
|
49
|
+
# DSL setter and reader in one: `model "id", **options` inside the block, `config.model`
|
|
50
|
+
# afterwards.
|
|
50
51
|
def model(id = nil, **options)
|
|
51
52
|
if id
|
|
52
53
|
@model_id = id
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# The resolved agent: everything discovered under the app's agent/
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# whose diagnostics say exactly what is wrong (surfaced by `rake xeno:info`).
|
|
4
|
+
# The resolved agent: everything discovered under the app's agent/ directory, plus diagnostics for
|
|
5
|
+
# anything misplaced or broken. Discovery never raises — a broken agent directory yields a
|
|
6
|
+
# definition whose diagnostics say exactly what is wrong (surfaced by `rake xeno:info`).
|
|
6
7
|
class AgentDefinition
|
|
7
8
|
SLOT_DIRS = %w[tools skills channels schedules hooks lib].freeze
|
|
8
9
|
TOOL_SLUG = /\A[a-z][a-z0-9_]*\z/
|
|
@@ -19,9 +20,9 @@ module Xeno
|
|
|
19
20
|
attr_reader :root, :name, :config, :instructions, :dynamic_instructions,
|
|
20
21
|
:tools, :schedules, :channels, :hooks, :diagnostics
|
|
21
22
|
|
|
22
|
-
# Builds the definition for the app's agent root. `resolver` maps a tool
|
|
23
|
-
#
|
|
24
|
-
#
|
|
23
|
+
# Builds the definition for the app's agent root. `resolver` maps a tool file's camelized
|
|
24
|
+
# basename to its constant; the default asks Zeitwerk (via const_get on Xeno::Tools) and is only
|
|
25
|
+
# overridden in tests.
|
|
25
26
|
def self.load(root, name:, resolver: nil)
|
|
26
27
|
new(root: root, name: name, resolver: resolver).tap(&:discover)
|
|
27
28
|
end
|
|
@@ -62,11 +63,9 @@ module Xeno
|
|
|
62
63
|
|
|
63
64
|
def dynamic_instructions? = !@dynamic_instructions.nil?
|
|
64
65
|
|
|
65
|
-
# The system prompt for one turn: static markdown first, the dynamic
|
|
66
|
-
#
|
|
67
|
-
# session
|
|
68
|
-
# bricks the session: the turn proceeds on the static instructions and
|
|
69
|
-
# the failure is logged.
|
|
66
|
+
# The system prompt for one turn: static markdown first, then the dynamic block's return,
|
|
67
|
+
# resolved fresh at every turn stage with the session context. A raising block never bricks the
|
|
68
|
+
# session — the turn proceeds on the static instructions and the failure is logged.
|
|
70
69
|
def instructions_for(session: nil)
|
|
71
70
|
parts = [ instructions ]
|
|
72
71
|
if @dynamic_instructions
|
|
@@ -116,9 +115,8 @@ module Xeno
|
|
|
116
115
|
end
|
|
117
116
|
end
|
|
118
117
|
|
|
119
|
-
# agent/instructions.rb — `Xeno.instructions do |context| ... end`,
|
|
120
|
-
#
|
|
121
|
-
# to the static markdown.
|
|
118
|
+
# agent/instructions.rb — `Xeno.instructions do |context| ... end`, resolved at turn-stage time
|
|
119
|
+
# (context: session, principal) and appended to the static markdown.
|
|
122
120
|
def load_dynamic_instructions
|
|
123
121
|
file = root.join("instructions.rb")
|
|
124
122
|
return unless file.file?
|
|
@@ -153,10 +151,9 @@ module Xeno
|
|
|
153
151
|
next
|
|
154
152
|
end
|
|
155
153
|
|
|
156
|
-
# Xeno::Tool required, not just RubyLLM::Tool: a plain RubyLLM
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
# gate would silently fail open.
|
|
154
|
+
# Xeno::Tool required, not just RubyLLM::Tool: a plain RubyLLM subclass leaks a namespaced
|
|
155
|
+
# wire name (`xeno--tools--foo`) that misses the slug-keyed lookup, and it has no approval
|
|
156
|
+
# API — the gate would silently fail open.
|
|
160
157
|
unless klass.is_a?(Class) && klass < Xeno::Tool
|
|
161
158
|
error "tools/#{file.basename}: Xeno::Tools::#{slug.camelize} must subclass Xeno::Tool " \
|
|
162
159
|
"(naming and the approval gate depend on it)"
|
|
@@ -171,9 +168,9 @@ module Xeno
|
|
|
171
168
|
Xeno::Tools.const_get(slug.camelize)
|
|
172
169
|
end
|
|
173
170
|
|
|
174
|
-
# agent/schedules/*.md — YAML frontmatter with a cron: line, body is the
|
|
175
|
-
#
|
|
176
|
-
#
|
|
171
|
+
# agent/schedules/*.md — YAML frontmatter with a cron: line, body is the task prompt. Compiled
|
|
172
|
+
# to Solid Queue recurring entries by `rake xeno:schedules:sync`; never fired on cadence in
|
|
173
|
+
# development.
|
|
177
174
|
def discover_schedules
|
|
178
175
|
dir = root.join("schedules")
|
|
179
176
|
return unless dir.directory?
|
|
@@ -202,9 +199,9 @@ module Xeno
|
|
|
202
199
|
end
|
|
203
200
|
end
|
|
204
201
|
|
|
205
|
-
# agent/channels/*.rb — DSL files (`Xeno.channel :slack do ... end`).
|
|
206
|
-
#
|
|
207
|
-
#
|
|
202
|
+
# agent/channels/*.rb — DSL files (`Xeno.channel :slack do ... end`). Loading registers the
|
|
203
|
+
# channel globally; the definition records what each file declared for diagnostics and
|
|
204
|
+
# xeno:info.
|
|
208
205
|
def discover_channels
|
|
209
206
|
dir = root.join("channels")
|
|
210
207
|
return unless dir.directory?
|
|
@@ -228,9 +225,9 @@ module Xeno
|
|
|
228
225
|
end
|
|
229
226
|
end
|
|
230
227
|
|
|
231
|
-
# agent/hooks/*.rb — observe-only handlers (`Xeno.hook "type" do … end`).
|
|
232
|
-
#
|
|
233
|
-
#
|
|
228
|
+
# agent/hooks/*.rb — observe-only handlers (`Xeno.hook "type" do … end`). One file may declare
|
|
229
|
+
# several; they merge across files. Handlers for unknown event types get a warning (typos never
|
|
230
|
+
# fire).
|
|
234
231
|
def discover_hooks
|
|
235
232
|
dir = root.join("hooks")
|
|
236
233
|
return unless dir.directory?
|
data/lib/xeno/arguments.rb
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
|
|
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
|
|
4
|
+
# Casts each tool argument to its declared parameter type. Models send JSON-typed arguments
|
|
5
|
+
# loosely ("4200" for an integer parameter); an uncoercible value becomes a validation error the
|
|
6
|
+
# runner turns into an error tool result for the model to retry — never an exception into execute.
|
|
7
|
+
module Arguments # :nodoc:
|
|
9
8
|
module_function
|
|
10
9
|
|
|
11
|
-
# Returns [coerced_arguments, errors]. Undeclared keys pass through
|
|
12
|
-
#
|
|
10
|
+
# Returns [coerced_arguments, errors]. Undeclared keys pass through untouched (schema-based
|
|
11
|
+
# tools declare nothing here).
|
|
13
12
|
def coerce(tool_class, arguments)
|
|
14
13
|
declared = tool_class.respond_to?(:declared_parameters) ? tool_class.declared_parameters : {}
|
|
15
14
|
return [ arguments, [] ] if declared.empty? || !arguments.is_a?(Hash)
|
data/lib/xeno/ask_question.rb
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# The
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# approvals. Registered on every chat (outside the Xeno::Tools namespace,
|
|
6
|
-
# which belongs to the app's agent/tools/).
|
|
4
|
+
# The built-in question tool. It has no executable body: the model calling it parks the turn, and
|
|
5
|
+
# a human's answer is injected as the tool result on resume — the same mechanics as approvals.
|
|
6
|
+
# Registered on every chat, outside the Xeno::Tools namespace that belongs to the app.
|
|
7
7
|
class AskQuestion < Tool
|
|
8
8
|
description "Ask the human a question and wait for their answer. " \
|
|
9
9
|
"Use when you need information or a decision only they can provide."
|
data/lib/xeno/channels/slack.rb
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "openssl"
|
|
2
4
|
require "net/http"
|
|
3
5
|
|
|
4
6
|
module Xeno
|
|
5
7
|
module Channels
|
|
6
|
-
# The Slack channel: Events API webhook with constant-time signature
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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.
|
|
8
|
+
# The Slack channel: Events API webhook with constant-time signature verification, thread-scoped
|
|
9
|
+
# sessions (continuation token = "slack:<channel>:<thread_ts>"), and plain-text approvals in the
|
|
10
|
+
# thread. Mentions and DMs start sessions; thread replies continue them. Post-then-edit
|
|
11
|
+
# streaming is opt-in (stream_replies); Block Kit buttons are not implemented.
|
|
13
12
|
class Slack
|
|
14
13
|
SIGNATURE_VERSION = "v0".freeze
|
|
15
14
|
TIMESTAMP_TOLERANCE = 300 # seconds; replayed webhooks are rejected
|
|
@@ -40,9 +39,9 @@ module Xeno
|
|
|
40
39
|
@api_base
|
|
41
40
|
end
|
|
42
41
|
|
|
43
|
-
# Opt-in post-then-edit streaming: the reply posts on the first model
|
|
44
|
-
#
|
|
45
|
-
#
|
|
42
|
+
# Opt-in post-then-edit streaming: the reply posts on the first model delta and is edited (~1s
|
|
43
|
+
# cadence, rate-limit aware) until the final text lands. Off by default — replies post once at
|
|
44
|
+
# completion.
|
|
46
45
|
def stream_replies(value = nil)
|
|
47
46
|
@stream_replies = value unless value.nil?
|
|
48
47
|
@stream_replies
|
|
@@ -82,8 +81,8 @@ module Xeno
|
|
|
82
81
|
post_message(channel: channel_id, thread_ts: thread_ts, text: content.to_s)
|
|
83
82
|
end
|
|
84
83
|
|
|
85
|
-
# One per content-bearing model reply when stream_replies is on: the
|
|
86
|
-
#
|
|
84
|
+
# One per content-bearing model reply when stream_replies is on: the runner pushes deltas, we
|
|
85
|
+
# post-then-edit in the thread.
|
|
87
86
|
def streamer_for(session)
|
|
88
87
|
return nil unless stream_replies
|
|
89
88
|
|
|
@@ -119,12 +118,10 @@ module Xeno
|
|
|
119
118
|
end
|
|
120
119
|
end
|
|
121
120
|
|
|
122
|
-
# Post-then-edit delivery for
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
# streaming off for this reply — the completion post is the fallback,
|
|
127
|
-
# and the durable truth is rows either way.
|
|
121
|
+
# Post-then-edit delivery for one streaming reply: the first content delta posts the thread
|
|
122
|
+
# message, later deltas edit it at most once per EDIT_INTERVAL, finish writes the final text.
|
|
123
|
+
# Rate limits defer the next edit; a hard failure turns streaming off for this reply and the
|
|
124
|
+
# completion post is the fallback.
|
|
128
125
|
class Streamer
|
|
129
126
|
EDIT_INTERVAL = 1.0 # seconds — comfortably under chat.update's tier
|
|
130
127
|
|
|
@@ -147,8 +144,8 @@ module Xeno
|
|
|
147
144
|
@ts.nil? ? start_message : edit_message(@buffer.dup)
|
|
148
145
|
end
|
|
149
146
|
|
|
150
|
-
# Returns true when this streamer delivered the reply (the runner
|
|
151
|
-
#
|
|
147
|
+
# Returns true when this streamer delivered the reply (the runner then skips the completion
|
|
148
|
+
# post); false hands delivery back.
|
|
152
149
|
def finish(final_text)
|
|
153
150
|
return false if @ts.nil?
|
|
154
151
|
|
|
@@ -196,8 +193,8 @@ module Xeno
|
|
|
196
193
|
end
|
|
197
194
|
end
|
|
198
195
|
|
|
199
|
-
# One JSON POST to the Slack Web API; 429 becomes RateLimited (with
|
|
200
|
-
#
|
|
196
|
+
# One JSON POST to the Slack Web API; 429 becomes RateLimited (with Retry-After), any other
|
|
197
|
+
# failure raises Xeno::Error.
|
|
201
198
|
def api_post(method, payload)
|
|
202
199
|
uri = URI("#{api_base}/#{method}")
|
|
203
200
|
request = Net::HTTP::Post.new(uri)
|
|
@@ -242,14 +239,13 @@ module Xeno
|
|
|
242
239
|
end
|
|
243
240
|
end
|
|
244
241
|
|
|
245
|
-
# A reply into a waiting thread resolves the
|
|
246
|
-
# unrelated reply is
|
|
247
|
-
# a forced answer
|
|
242
|
+
# A reply into a waiting thread resolves the first pending input;
|
|
243
|
+
# an unrelated reply is held as the next message — never an
|
|
244
|
+
# auto-deny, never a forced answer.
|
|
248
245
|
# - approvals: approve/deny words resolve; anything else holds.
|
|
249
|
-
# - questions with choices: match by 1-based index
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
# - free-form questions (no choices): any text is the answer.
|
|
246
|
+
# - questions with choices: match by 1-based index, label, or exact
|
|
247
|
+
# option text; no match holds.
|
|
248
|
+
# - free-form questions: any text is the answer.
|
|
253
249
|
def continue_session(session, text, principal)
|
|
254
250
|
pending = Action.joins(:turn)
|
|
255
251
|
.where(xeno_turns: { session_id: session.id }, status: "pending_approval")
|
data/lib/xeno/channels.rb
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# The channel registry. A channel
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# the rest via the DSL:
|
|
4
|
+
# The channel registry. A channel normalizes platform input into user messages, owns the session's
|
|
5
|
+
# continuation token, and decides delivery of agent output. HTTP is built in; agent/channels/*.rb
|
|
6
|
+
# declare the rest:
|
|
6
7
|
#
|
|
7
8
|
# Xeno.channel :slack do
|
|
8
9
|
# signing_secret Rails.application.credentials.dig(:slack, :signing_secret)
|
|
9
10
|
# bot_token Rails.application.credentials.dig(:slack, :bot_token)
|
|
10
11
|
# end
|
|
11
12
|
#
|
|
12
|
-
# Delivery is a failable nicety: the durable truth lives in the transcript
|
|
13
|
-
#
|
|
13
|
+
# Delivery is a failable nicety: the durable truth lives in the transcript and event rows, so a
|
|
14
|
+
# failed post logs and moves on.
|
|
14
15
|
module Channels
|
|
15
16
|
module_function
|
|
16
17
|
|
|
@@ -27,7 +28,7 @@ module Xeno
|
|
|
27
28
|
when :slack
|
|
28
29
|
Slack.new(&block)
|
|
29
30
|
else
|
|
30
|
-
raise Xeno::Error, "unknown channel type: #{name} (
|
|
31
|
+
raise Xeno::Error, "unknown channel type: #{name} (:slack is bundled; http is built in)"
|
|
31
32
|
end
|
|
32
33
|
end
|
|
33
34
|
|
|
@@ -47,8 +48,8 @@ module Xeno
|
|
|
47
48
|
Rails.logger.warn("xeno: channel input-request delivery failed for session #{session.id}: #{e.class}: #{e.message}")
|
|
48
49
|
end
|
|
49
50
|
|
|
50
|
-
# A per-reply delta sink (post-then-edit streaming) when the session's
|
|
51
|
-
#
|
|
51
|
+
# A per-reply delta sink (post-then-edit streaming) when the session's channel opts in; nil
|
|
52
|
+
# otherwise. Failable nicety like all delivery.
|
|
52
53
|
def streamer_for(session)
|
|
53
54
|
channel = for_session(session)
|
|
54
55
|
return nil unless channel.respond_to?(:streamer_for)
|
data/lib/xeno/compaction.rb
CHANGED
|
@@ -1,20 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# Summarize-and-replace compaction,
|
|
3
|
-
#
|
|
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).
|
|
4
|
+
# Summarize-and-replace compaction, run as a claimed turn (kind: "compaction") so it is exclusive,
|
|
5
|
+
# queues behind active work, and replays safely after a crash.
|
|
8
6
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
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
|
|
7
|
+
# The system prompt and the last `compaction_tail_turns` turns survive verbatim; everything before
|
|
8
|
+
# them is summarized by a throwaway model call and replaced in place.
|
|
9
|
+
module Compaction # :nodoc:
|
|
18
10
|
SUMMARY_PREFIX = "[Conversation summary — earlier messages were compacted]".freeze
|
|
19
11
|
|
|
20
12
|
COMPACTION_PROMPT = <<~PROMPT.freeze
|
|
@@ -50,8 +42,9 @@ module Xeno
|
|
|
50
42
|
(turn.user_message || {}).key?("result")
|
|
51
43
|
end
|
|
52
44
|
|
|
53
|
-
# Everything strictly before the tail is compactable. The tail starts at
|
|
54
|
-
#
|
|
45
|
+
# Everything strictly before the tail is compactable. The tail starts at the Nth-from-last user
|
|
46
|
+
# row — a cut at a user row never splits an assistant's tool calls from their results. With too
|
|
47
|
+
# few turns there is nothing to do.
|
|
55
48
|
def compaction_plan(rows)
|
|
56
49
|
system_rows, convo = rows.partition { |row| row.role == "system" }
|
|
57
50
|
user_positions = convo.each_index.select { |i| convo[i].role == "user" }
|
|
@@ -65,9 +58,8 @@ module Xeno
|
|
|
65
58
|
end
|
|
66
59
|
end
|
|
67
60
|
|
|
68
|
-
# One model call on a throwaway chat
|
|
69
|
-
#
|
|
70
|
-
# earlier compaction) is just an early row here and flows in whole.
|
|
61
|
+
# One model call on a throwaway chat: no persistence, no tools. An earlier compaction's summary
|
|
62
|
+
# is an ordinary early row here.
|
|
71
63
|
def compaction_summary(rows)
|
|
72
64
|
options = definition.config.model_options
|
|
73
65
|
llm = RubyLLM.chat(
|
|
@@ -92,17 +84,16 @@ module Xeno
|
|
|
92
84
|
}.join("\n\n")
|
|
93
85
|
end
|
|
94
86
|
|
|
95
|
-
# The
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
# is a pure no-op past this transaction.
|
|
87
|
+
# The earliest compacted row becomes the summary in place; the rest are destroyed newest-first.
|
|
88
|
+
# Rows, marker, and event commit together, so replay after a crash is a no-op past this
|
|
89
|
+
# transaction.
|
|
99
90
|
def apply_compaction!(plan, summary)
|
|
100
91
|
keeper, *rest = plan[:compact]
|
|
101
92
|
|
|
102
93
|
ActiveRecord::Base.transaction do
|
|
103
94
|
rest.reverse_each(&:destroy!)
|
|
104
|
-
# A parent tool call on a surviving earlier row would otherwise
|
|
105
|
-
#
|
|
95
|
+
# A parent tool call on a surviving earlier row would otherwise dangle a result FK at the
|
|
96
|
+
# repurposed keeper.
|
|
106
97
|
keeper.ruby_llm_tool_calls.destroy_all
|
|
107
98
|
keeper.ruby_llm_parent_tool_call&.update!(result: nil)
|
|
108
99
|
keeper.update!(
|
|
@@ -123,10 +114,8 @@ module Xeno
|
|
|
123
114
|
end
|
|
124
115
|
end
|
|
125
116
|
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
# threshold share of the model's window (config fallback when the
|
|
129
|
-
# registry doesn't know the window).
|
|
117
|
+
# After a message turn completes, stage a compaction turn when the last model call crossed the
|
|
118
|
+
# threshold share of the model's window (config fallback when the registry doesn't know it).
|
|
130
119
|
def maybe_stage_compaction
|
|
131
120
|
threshold = Xeno.config.compaction_threshold
|
|
132
121
|
return unless threshold
|
|
@@ -151,9 +140,7 @@ module Xeno
|
|
|
151
140
|
window || Xeno.config.compaction_context_window
|
|
152
141
|
end
|
|
153
142
|
|
|
154
|
-
#
|
|
155
|
-
# "how full is the context" number, from the per-attempt usage ledger
|
|
156
|
-
# (message rows no longer carry token columns).
|
|
143
|
+
# The provider's count for the last completed call, read from the per-attempt usage ledger.
|
|
157
144
|
def compaction_tokens_used
|
|
158
145
|
last = @chat.ruby_llm_usages.where(operation: "chat", status: "succeeded")
|
|
159
146
|
.where.not(input_tokens: nil).order(:created_at, :id).last
|