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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '03921563a3c8f8a6ef1a2f6c8274f6851f65daeebe66b617e2fc9729673bd571'
|
|
4
|
+
data.tar.gz: 51e0a0f0646cc28974e73e8cca0e5c485fc60113c664161a0e31b74d6bfb5201
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8a5e8aa4d6ce79696fc85275c07ddb4522c2fd18b667120087841367d94a279258763ce9ab45bb7764556e8954b70f1f751f6b88549983c4622ea58eea2a3cc6
|
|
7
|
+
data.tar.gz: cdb8ceae13f48c5277d9760715021749d6ddfa40fa0a9a7045631c7cbb6323096e17aabd097b3b0b0d37c3513d7a4f9f1e7d4585b654bee683955b56a71bbf46
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Format follows [Keep a Changelog](https://keepachangelog.com); versions follow
|
|
4
|
+
[SemVer](https://semver.org). **xeno is on the `0.0.x` preview line: any release may
|
|
5
|
+
change anything.** `0.1.0` will mark the first release we consider usable in anger —
|
|
6
|
+
after RubyLLM v2 GA is absorbed and the API has held still through early-adopter
|
|
7
|
+
feedback.
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [0.0.1] - 2026-08-07
|
|
12
|
+
|
|
13
|
+
The first public release of xeno, a framework for durable AI agents built on Rails:
|
|
14
|
+
|
|
15
|
+
- `agent/` conventions with path-derived names (`agent.rb`, `instructions.md`,
|
|
16
|
+
dynamic `instructions.rb`, `tools/`, `hooks/`, `schedules/`, `channels/`).
|
|
17
|
+
- Durable session → turn → step runtime on ActiveJob: atomic claims, fencing tokens,
|
|
18
|
+
heartbeats (including during long calls), checkpoint replay, a reaper for orphaned
|
|
19
|
+
turns, graceful-shutdown handoff, per-turn step budgets.
|
|
20
|
+
- Human-in-the-loop parking: tool `approval` policies, `ask_question`, crash-safe
|
|
21
|
+
approve→resume, full text resolution of prompts (labels, indexes, held replies).
|
|
22
|
+
- Append-only event stream (SSE + NDJSON) with cursor replay; the events table doubles
|
|
23
|
+
as an audit log.
|
|
24
|
+
- Compaction, per-session token budgets, session-scoped state, steering, pending-message
|
|
25
|
+
queueing with drain-on-park.
|
|
26
|
+
- Channels: HTTP (fail-closed auth, per-principal session ownership) and Slack
|
|
27
|
+
(signature verification, event dedup, thread sessions, text approvals, optional
|
|
28
|
+
post-then-edit streaming).
|
|
29
|
+
- Markdown cron schedules compiled to Solid Queue recurring entries, with dedup.
|
|
30
|
+
- Generators (`xeno:install`, `xeno:tool`) and standalone mode: `xeno new my-agent` /
|
|
31
|
+
`xeno server` — one process, sqlite, zero services. Scaffolds include a pinned
|
|
32
|
+
`ruby_llm` Gemfile, dotenv, and an AGENTS.md pointing coding agents at the gem docs.
|
|
33
|
+
- Standalone first boot refreshes the model registry after the boot migrations, so
|
|
34
|
+
current model ids resolve without a manual `RubyLLM.models.refresh!`.
|
|
35
|
+
- Tracks RubyLLM v2 (unreleased) at a pinned git commit: transcript support tables
|
|
36
|
+
(`ruby_llm_*`), per-attempt usage ledger backing token budgets and compaction.
|
|
37
|
+
- Test suite: 180 runs / 987 assertions on SQLite and PostgreSQL, plus end-to-end
|
|
38
|
+
kill-and-resume scripts with real processes and real signals.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nicolas Erlichman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# xeno
|
|
2
|
+
|
|
3
|
+
xeno is a framework for durable AI agents. Built on Rails, it runs standalone (`xeno new my-agent`) or inside the Rails app you already have.
|
|
4
|
+
|
|
5
|
+
A xeno agent is a session that survives restarts and deploys mid-turn, waits days for a human approval without holding a process, and resumes exactly where it stopped. Under the hood, xeno composes [RubyLLM](https://rubyllm.com) for model calls with ActiveJob and ActiveRecord for durability.
|
|
6
|
+
|
|
7
|
+
> [!WARNING]
|
|
8
|
+
> xeno currently tracks RubyLLM v2 (unreleased), pinned to git ref [`2aaddf96`](https://github.com/crmne/ruby_llm/commit/2aaddf96). The released `ruby_llm` gem (1.16.0) is not compatible. Standalone apps get the pin from the scaffold's Gemfile. If your Rails app already uses a released `ruby_llm`, xeno cannot run alongside it today. This resolves at RubyLLM v2 GA, when xeno switches to the released gem.
|
|
9
|
+
|
|
10
|
+
## The filesystem is the authoring interface
|
|
11
|
+
|
|
12
|
+
A xeno agent is a directory:
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
agent/
|
|
16
|
+
├── agent.rb # optional: model and runtime config
|
|
17
|
+
├── instructions.md # the always-on system prompt
|
|
18
|
+
├── instructions.rb # optional: dynamic instructions, resolved per turn
|
|
19
|
+
├── tools/
|
|
20
|
+
│ └── get_weather.rb # tool "get_weather" (class Xeno::Tools::GetWeather)
|
|
21
|
+
├── hooks/
|
|
22
|
+
│ └── metrics.rb # observe-only event handlers
|
|
23
|
+
└── schedules/
|
|
24
|
+
└── weekly_recap.md # cron-triggered agent session
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The path supplies the name; there are no `name:` fields. `xeno info` prints the resolved agent and flags anything misplaced.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
Standalone, from nothing:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
xeno new my-agent && cd my-agent
|
|
35
|
+
bundle install
|
|
36
|
+
xeno server
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
One process: web and jobs in the same Puma, SQLite, no external services. The dev chat UI is at `http://localhost:3000/agent/dev`.
|
|
40
|
+
|
|
41
|
+
In an existing Rails app:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
bundle add xeno
|
|
45
|
+
bin/rails g xeno:install
|
|
46
|
+
bin/rails db:migrate
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The generator creates `agent/` at your app root and mounts the engine at `/agent`. Tools are your domain code: they call your models, policies, and credentials directly, with no connector layer in between.
|
|
50
|
+
|
|
51
|
+
## A minimal example
|
|
52
|
+
|
|
53
|
+
Add a gated tool at `agent/tools/charge_card.rb`:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class Xeno::Tools::ChargeCard < Xeno::Tool
|
|
57
|
+
description "Charge the customer's card. Irreversible."
|
|
58
|
+
parameter :amount_cents, type: :integer, description: "Amount in cents"
|
|
59
|
+
|
|
60
|
+
approval :always
|
|
61
|
+
|
|
62
|
+
def execute(amount_cents:)
|
|
63
|
+
PaymentService.charge!(amount_cents)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Send "charge $42 to my card" and the agent decides to call the tool. `approval :always` parks the turn: the job ends, and the pending approval is a database row. Kill the process, deploy, come back tomorrow. When you approve (over HTTP, or one click in the dev UI), the turn resumes from its last checkpoint and the tool runs exactly once.
|
|
69
|
+
|
|
70
|
+
That arc is the flagship integration test (`test/demo_acceptance_test.rb`, three real processes) and a watchable script (`script/demo.sh`).
|
|
71
|
+
|
|
72
|
+
## How durability works
|
|
73
|
+
|
|
74
|
+
Work nests session → turn → step. A turn is one ActiveJob; a step is one model call plus its tool calls, checkpointed in your database:
|
|
75
|
+
|
|
76
|
+
- A tool execution and its transcript write commit in one transaction, so recorded calls never re-execute. A killed process resumes from the last checkpoint.
|
|
77
|
+
- Turn claims use an atomic compare-and-swap with heartbeats, stale-claim takeover, fencing tokens, and a reaper for orphaned turns. Any ActiveJob backend works; with Solid Queue the whole story is database rows.
|
|
78
|
+
- Parking ends the job. A parked session costs nothing for days; resolving its pending input enqueues the resume.
|
|
79
|
+
- Messages that arrive mid-turn queue up and fold into the next turn.
|
|
80
|
+
|
|
81
|
+
`docs/runtime.md`, shipped inside the gem, documents the runtime invariants in full.
|
|
82
|
+
|
|
83
|
+
## Sessions over HTTP
|
|
84
|
+
|
|
85
|
+
The engine mounts at `/agent`:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
POST /agent/v1/sessions create a session → { session_id, continuation_token }
|
|
89
|
+
POST /agent/v1/sessions/:id/messages follow-up message (steer: true replaces the active turn)
|
|
90
|
+
POST /agent/v1/sessions/:id/inputs approve / deny / answer (by action_id)
|
|
91
|
+
POST /agent/v1/sessions/:id/cancel stop the active turn
|
|
92
|
+
POST /agent/v1/sessions/:id/compact summarize old history
|
|
93
|
+
POST /agent/v1/sessions/:id/reset retire the session
|
|
94
|
+
GET /agent/v1/sessions/:id/stream event stream
|
|
95
|
+
GET /agent/v1/health
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Events are append-only rows with a per-session index. The stream endpoint serves Server-Sent Events (SSE) or NDJSON (`?format=ndjson`) and replays from any cursor (`?start_index=N`); the same table is your audit log.
|
|
99
|
+
|
|
100
|
+
## Slack
|
|
101
|
+
|
|
102
|
+
Two credentials and the agent answers mentions in threads:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
# agent/channels/slack.rb
|
|
106
|
+
Xeno.channel :slack do
|
|
107
|
+
signing_secret ENV["SLACK_SIGNING_SECRET"]
|
|
108
|
+
bot_token ENV["SLACK_BOT_TOKEN"]
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Standalone apps load these from `.env`; in a Rails app, `Rails.application.credentials` works here too.
|
|
113
|
+
|
|
114
|
+
Point your Slack app's Events API at `POST /agent/v1/channels/slack/events`. xeno verifies signatures in constant time, answers the URL handshake, dedupes retries, and acks inside Slack's 3-second window. Mentions and direct messages start sessions; each thread is one session; replies continue it.
|
|
115
|
+
|
|
116
|
+
When the agent parks, the prompt lands in the thread, and replies resolve it:
|
|
117
|
+
|
|
118
|
+
- **approve** or **deny** settles an approval
|
|
119
|
+
- a number ("2") or a label ("tuesday") picks a choice
|
|
120
|
+
- any text answers a free-form question
|
|
121
|
+
- unrelated replies are held as the next message, never treated as an answer
|
|
122
|
+
|
|
123
|
+
Replies post at turn completion by default; `stream_replies true` in the channel block opts into post-then-edit streaming.
|
|
124
|
+
|
|
125
|
+
## Schedules
|
|
126
|
+
|
|
127
|
+
A schedule is a markdown file with a cron line:
|
|
128
|
+
|
|
129
|
+
```markdown
|
|
130
|
+
---
|
|
131
|
+
cron: "0 9 * * 1"
|
|
132
|
+
---
|
|
133
|
+
Summarize the week's activity and post highlights to the team.
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`bin/rails xeno:schedules:sync` compiles `agent/schedules/*.md` into Solid Queue recurring entries; standalone apps sync at boot. Development never fires on cadence: trigger by name via the dev endpoint. Schedule runs execute under the app principal and cannot wait on a human, so a gated tool deterministically fails the run.
|
|
137
|
+
|
|
138
|
+
## Configuration
|
|
139
|
+
|
|
140
|
+
Standalone apps configure in `agent/agent.rb`; mounted apps use `config/initializers/xeno.rb`:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
Xeno.configure do |config|
|
|
144
|
+
# Fail closed: every endpoint except health returns 401 until you set this.
|
|
145
|
+
# The truthy return value becomes the session principal.
|
|
146
|
+
config.authenticate = ->(request) do
|
|
147
|
+
token = request.headers["Authorization"]&.delete_prefix("Bearer ")
|
|
148
|
+
{ "user" => "api" } if ActiveSupport::SecurityUtils.secure_compare(
|
|
149
|
+
token.to_s, ENV["XENO_API_KEY"].to_s
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
config.max_steps = 20 # per-turn model-call budget
|
|
154
|
+
config.turn_stale_after = 5.minutes # dead-worker takeover window
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Development with no lambda configured stays usable on localhost; everywhere else the absence of a checker is a 401.
|
|
159
|
+
|
|
160
|
+
The principal is also the ownership boundary: a session belongs to the principal stamped at creation, and every session-scoped endpoint returns 404 for any other principal. See `docs/runtime.md` for the authorization rules.
|
|
161
|
+
|
|
162
|
+
### Model registry
|
|
163
|
+
|
|
164
|
+
Model ids resolve against RubyLLM's registry, and models released after the gem's catalog snapshot need a registry refresh before they resolve. Standalone apps refresh automatically on first boot, right after the boot-time migrations (skip with `XENO_SKIP_MODEL_REFRESH=1`). To refresh later, run it manually for now (a friendlier command is planned):
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
# mounted
|
|
168
|
+
bin/rails runner "RubyLLM.models.refresh!"
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
# standalone
|
|
173
|
+
bundle exec ruby -r dotenv/load -r xeno/standalone -e 'Xeno.rails_app; Xeno.definition; RubyLLM.models.refresh!'
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Or bypass the registry per model with `model "the-id", provider: :openai, assume_model_exists: true`. Automatic compaction then needs `config.compaction_context_window`, since the registry cannot supply the window.
|
|
177
|
+
|
|
178
|
+
## Security model
|
|
179
|
+
|
|
180
|
+
xeno runs tools inside your app on purpose:
|
|
181
|
+
|
|
182
|
+
- There is no sandbox and no `bash` tool. The model's only execution surface is the tools you write.
|
|
183
|
+
- That power cuts both ways: prompt injection reaches whatever your tools expose. Keep parameters narrow and typed, scope queries to the session's principal, authorize inside tools with your existing layer, and treat tool output fed back to the model as untrusted.
|
|
184
|
+
- Approvals are the guardrail. Gate anything irreversible or externally visible with `:once` or `:always`.
|
|
185
|
+
- Webhooks verify signatures in constant time, HTTP auth fails closed, and dev routes never mount outside development.
|
|
186
|
+
|
|
187
|
+
## Known limitations
|
|
188
|
+
|
|
189
|
+
- SSE holds a thread per client, which is fine for dev UIs and small deployments. Catch-up reads are batched so reconnects on long sessions stay bounded.
|
|
190
|
+
- SQLite and PostgreSQL are the tested databases; the suite runs on both. MySQL is untested and has known caveats.
|
|
191
|
+
- Execution is at-least-once: a step interrupted mid-flight re-runs on retry, so tool side effects need idempotency or approval gates. Consumers dedupe events by `(session, index)`.
|
|
192
|
+
- Cancellation is cooperative and lands at the next step boundary; an in-flight model call finishes first. A parked turn cancels instantly.
|
|
193
|
+
- Sessions retire explicitly: `reset` is the only terminal transition.
|
|
194
|
+
- One agent per app.
|
|
195
|
+
|
|
196
|
+
## Development
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
bin/rails test # the whole suite: offline, deterministic fake LLM
|
|
200
|
+
script/demo.sh # the kill -9 demo, narrated
|
|
201
|
+
script/verify_generators.sh # generators against a fresh rails new app
|
|
202
|
+
script/verify_standalone.sh # the standalone one-process arc
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Tests run against a scripted fake server: no network, no keys, no flake. The kill-and-resume arcs use real processes and real signals.
|
|
206
|
+
|
|
207
|
+
## Status: preview (0.0.x)
|
|
208
|
+
|
|
209
|
+
xeno is an early preview with no production mileage yet; anything may change between 0.0.x releases. It targets RubyLLM v2 (unreleased, pinned to a known-good commit; see the warning above); the pin drops at v2 GA. `0.1.0` will mark the first release we consider stable enough to build on. See [CHANGELOG.md](CHANGELOG.md).
|
|
210
|
+
|
|
211
|
+
MIT License.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# Base for every engine endpoint. Auth fails closed: without a configured
|
|
3
|
+
# authenticate lambda, everything is 401. The lambda receives the request;
|
|
4
|
+
# a falsy return is a 401; its truthy return value is the request
|
|
5
|
+
# principal (stamped onto sessions by the HTTP channel).
|
|
6
|
+
class ApiController < ActionController::API
|
|
7
|
+
before_action :authenticate!
|
|
8
|
+
|
|
9
|
+
rescue_from ActiveRecord::RecordNotFound do
|
|
10
|
+
render json: { error: "not found" }, status: :not_found
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
rescue_from ActionController::ParameterMissing do |error|
|
|
14
|
+
render json: { error: error.message }, status: :bad_request
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
rescue_from Xeno::Error do |error|
|
|
18
|
+
render json: { error: error.message }, status: :conflict
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The development fallback principal (no auth lambda configured). It
|
|
22
|
+
# matches every session — in development only — so the dev UI and
|
|
23
|
+
# localhost curl stay usable.
|
|
24
|
+
DEV_PRINCIPAL = { "user" => "dev" }.freeze
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def authenticate!
|
|
29
|
+
checker = Xeno.config.authenticate
|
|
30
|
+
|
|
31
|
+
# Development with no auth configured stays usable (the dev UI, curl on
|
|
32
|
+
# localhost). Everywhere else the absence of a checker is a 401.
|
|
33
|
+
if checker.nil? && Rails.env.development?
|
|
34
|
+
@current_principal = DEV_PRINCIPAL
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@current_principal = checker && instance_exec(request, &checker)
|
|
39
|
+
head :unauthorized unless @current_principal
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def current_principal
|
|
43
|
+
# Normalize truthy-but-not-descriptive results (true) to nil-principal.
|
|
44
|
+
@current_principal == true ? nil : @current_principal
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Ownership rule: a session belongs to the principal stamped at create.
|
|
48
|
+
# A non-matching caller gets the same 404 as a nonexistent id (no
|
|
49
|
+
# existence leak). A lambda that returns bare `true` yields nil
|
|
50
|
+
# principals everywhere — single-tenant mode, every authenticated
|
|
51
|
+
# caller matches (documented in docs/runtime.md).
|
|
52
|
+
def find_owned_session!
|
|
53
|
+
session = Session.find(params[:id])
|
|
54
|
+
raise ActiveRecord::RecordNotFound unless owns?(session)
|
|
55
|
+
|
|
56
|
+
session
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def owns?(session)
|
|
60
|
+
# Object identity on purpose: only the fallback path above grants the
|
|
61
|
+
# wildcard, never a configured lambda that happens to return the same
|
|
62
|
+
# hash.
|
|
63
|
+
return true if Rails.env.development? && @current_principal.equal?(DEV_PRINCIPAL)
|
|
64
|
+
|
|
65
|
+
session.principal == current_principal.as_json
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# Development conveniences. Schedules never fire on cadence in dev; this
|
|
3
|
+
# endpoint triggers one by name. Refuses to exist outside
|
|
4
|
+
# development (XENO_DEV_ROUTES=1 lets tests exercise it).
|
|
5
|
+
class DevController < ApiController
|
|
6
|
+
before_action :ensure_development
|
|
7
|
+
|
|
8
|
+
def dispatch_schedule
|
|
9
|
+
name = params[:name].to_s
|
|
10
|
+
unless Xeno.definition.schedules.key?(name)
|
|
11
|
+
return render json: { error: "unknown schedule: #{name}" }, status: :not_found
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
ScheduleJob.perform_later(name)
|
|
15
|
+
render json: { dispatched: name }, status: :accepted
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def ensure_development
|
|
21
|
+
head :not_found unless Xeno.dev_routes_enabled?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# The mounted dev chat UI: chat with the
|
|
3
|
+
# agent, watch the event stream live (over xeno's own SSE endpoint), and
|
|
4
|
+
# approve/deny/answer parked work. Development only.
|
|
5
|
+
class DevUiController < ApplicationController
|
|
6
|
+
before_action :ensure_development
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@sessions = Session.order(id: :desc).limit(50)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
session_record = Session.start!(
|
|
14
|
+
message: params.require(:message),
|
|
15
|
+
channel: "dev",
|
|
16
|
+
principal: { "user" => "dev-ui" }
|
|
17
|
+
)
|
|
18
|
+
redirect_to dev_session_path(session_record)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def show
|
|
22
|
+
@session = Session.find(params[:id])
|
|
23
|
+
@messages = @session.chat.messages_association.order(:id)
|
|
24
|
+
@pending_actions = Action.joins(:turn)
|
|
25
|
+
.where(xeno_turns: { session_id: @session.id }, status: "pending_approval")
|
|
26
|
+
.order(:id)
|
|
27
|
+
@events = @session.events.order(:index)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def message
|
|
31
|
+
session_record = Session.find(params[:id])
|
|
32
|
+
content = params.require(:message)
|
|
33
|
+
if ActiveModel::Type::Boolean.new.cast(params[:steer])
|
|
34
|
+
session_record.steer!(content)
|
|
35
|
+
else
|
|
36
|
+
session_record.receive_message!(content)
|
|
37
|
+
end
|
|
38
|
+
redirect_to dev_session_path(session_record)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def compact
|
|
42
|
+
session_record = Session.find(params[:id])
|
|
43
|
+
unless session_record.turns.where(kind: "compaction", status: %w[pending running]).exists?
|
|
44
|
+
session_record.stage_compaction_turn!(reason: "manual").enqueue!
|
|
45
|
+
end
|
|
46
|
+
redirect_to dev_session_path(session_record)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def input
|
|
50
|
+
session_record = Session.find(params[:id])
|
|
51
|
+
action = Action.joins(:turn)
|
|
52
|
+
.where(xeno_turns: { session_id: session_record.id })
|
|
53
|
+
.find(params.require(:action_id))
|
|
54
|
+
|
|
55
|
+
principal = { "user" => "dev-ui" }
|
|
56
|
+
case params[:decision]
|
|
57
|
+
when "approve" then Inputs.approve!(action, principal: principal)
|
|
58
|
+
when "deny" then Inputs.deny!(action, reason: params[:reason], principal: principal)
|
|
59
|
+
else Inputs.answer!(action, params.require(:answer), principal: principal)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
redirect_to dev_session_path(session_record)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def ensure_development
|
|
68
|
+
head :not_found unless Xeno.dev_routes_enabled?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# The HTTP channel: always mounted, fail-closed. The channel owns the
|
|
3
|
+
# continuation token (the resume handle): create returns one, follow-up
|
|
4
|
+
# messages must present it, terminal states release it, reset retires the
|
|
5
|
+
# session so the same token can start fresh.
|
|
6
|
+
class SessionsController < ApiController
|
|
7
|
+
# POST /v1/sessions { message:, continuation_token: (optional) }
|
|
8
|
+
# Client-supplied tokens are restricted to the reserved `http:`
|
|
9
|
+
# namespace — a crafted `slack:<channel>:<ts>` token would otherwise
|
|
10
|
+
# hijack which Slack thread the agent posts into.
|
|
11
|
+
def create
|
|
12
|
+
message = params.require(:message)
|
|
13
|
+
token = params[:continuation_token].presence
|
|
14
|
+
if token && !token.start_with?("http:")
|
|
15
|
+
return render json: { error: "client-supplied continuation_token must use the http: namespace" },
|
|
16
|
+
status: :unprocessable_entity
|
|
17
|
+
end
|
|
18
|
+
token ||= "http:#{SecureRandom.base58(24)}"
|
|
19
|
+
|
|
20
|
+
session = Session.start!(
|
|
21
|
+
message: message,
|
|
22
|
+
channel: "http",
|
|
23
|
+
principal: current_principal,
|
|
24
|
+
continuation_token: token
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
render json: { session_id: session.id, continuation_token: session.continuation_token },
|
|
28
|
+
status: :created
|
|
29
|
+
rescue ActiveRecord::RecordNotUnique
|
|
30
|
+
render json: { error: "continuation_token already in use by an active session" },
|
|
31
|
+
status: :conflict
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# POST /v1/sessions/:id/messages { message:, continuation_token:, steer: }
|
|
35
|
+
# steer: true stops the active turn (settled safely) and makes this
|
|
36
|
+
# message the next turn instead of queueing behind the current work.
|
|
37
|
+
def message
|
|
38
|
+
session = find_owned_session!
|
|
39
|
+
|
|
40
|
+
return render json: { error: "session is finished — start a new one" }, status: :gone unless session.active?
|
|
41
|
+
|
|
42
|
+
supplied = params[:continuation_token].to_s
|
|
43
|
+
unless supplied.present? && session.continuation_token.present? &&
|
|
44
|
+
ActiveSupport::SecurityUtils.secure_compare(supplied, session.continuation_token)
|
|
45
|
+
return render json: { error: "continuation_token missing or wrong" }, status: :forbidden
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
content = params.require(:message)
|
|
49
|
+
steer = ActiveModel::Type::Boolean.new.cast(params[:steer])
|
|
50
|
+
turn = steer ? session.steer!(content) : session.receive_message!(content)
|
|
51
|
+
render json: { session_id: session.id, queued: turn.nil?, turn_id: turn&.id, steered: steer || nil }.compact,
|
|
52
|
+
status: :accepted
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# POST /v1/sessions/:id/inputs { action_id:, decision: approve|deny, reason: } or { action_id:, answer: }
|
|
56
|
+
def input
|
|
57
|
+
session = find_owned_session!
|
|
58
|
+
action = Action.joins(:turn).where(xeno_turns: { session_id: session.id })
|
|
59
|
+
.find(params.require(:action_id))
|
|
60
|
+
|
|
61
|
+
if action.kind == "question"
|
|
62
|
+
Inputs.answer!(action, params.require(:answer), principal: current_principal)
|
|
63
|
+
else
|
|
64
|
+
case params.require(:decision)
|
|
65
|
+
when "approve" then Inputs.approve!(action, principal: current_principal)
|
|
66
|
+
when "deny" then Inputs.deny!(action, reason: params[:reason], principal: current_principal)
|
|
67
|
+
else
|
|
68
|
+
return render json: { error: "decision must be approve or deny" }, status: :unprocessable_entity
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
render json: { action_id: action.id, status: action.reload.status,
|
|
73
|
+
turn_status: action.turn.reload.status }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# POST /v1/sessions/:id/cancel — stop the active turn, session intact.
|
|
77
|
+
# A parked/pending turn cancels immediately (no process holds it); a
|
|
78
|
+
# running turn is cancelled cooperatively via the persisted flag the
|
|
79
|
+
# runner's checker polls (takes effect within a chunk or step boundary).
|
|
80
|
+
def cancel
|
|
81
|
+
session = find_owned_session!
|
|
82
|
+
turn = session.turns.where(status: %w[pending running waiting]).order(:sequence).first
|
|
83
|
+
|
|
84
|
+
return render json: { error: "no active turn" }, status: :conflict unless turn
|
|
85
|
+
|
|
86
|
+
if turn.status == "running"
|
|
87
|
+
session.chat.cancel!
|
|
88
|
+
render json: { turn_id: turn.id, cancelling: true }, status: :accepted
|
|
89
|
+
else
|
|
90
|
+
# Settle BEFORE cancelling: a parked turn's assistant tool_calls are
|
|
91
|
+
# still unanswered, and leaving them dangling bricks the session
|
|
92
|
+
# (every later generate replays the malformed transcript).
|
|
93
|
+
session.settle_unanswered_tool_calls!(turn, reason: "cancelled by user")
|
|
94
|
+
turn.update!(status: "cancelled")
|
|
95
|
+
session.update!(status: "running") if session.status == "waiting"
|
|
96
|
+
session.emit("turn.cancelled", { turn_id: turn.id })
|
|
97
|
+
render json: { turn_id: turn.id, cancelled: true }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# POST /v1/sessions/:id/compact — request a manual compaction. It runs
|
|
102
|
+
# as a claimed turn, so it queues behind any active or parked turn and
|
|
103
|
+
# executes the moment the session is free. Never appends a user message.
|
|
104
|
+
def compact
|
|
105
|
+
session = find_owned_session!
|
|
106
|
+
|
|
107
|
+
return render json: { error: "session is finished" }, status: :gone unless session.active?
|
|
108
|
+
if session.turns.where(kind: "compaction", status: %w[pending running]).exists?
|
|
109
|
+
return render json: { error: "a compaction is already queued" }, status: :conflict
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
turn = session.stage_compaction_turn!(reason: "manual")
|
|
113
|
+
turn.enqueue!
|
|
114
|
+
render json: { session_id: session.id, turn_id: turn.id, status: "requested" }, status: :accepted
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# POST /v1/sessions/:id/reset — retire the session (the /new command);
|
|
118
|
+
# releases the continuation token so the same handle can start fresh.
|
|
119
|
+
def reset
|
|
120
|
+
session = find_owned_session!
|
|
121
|
+
|
|
122
|
+
return render json: { error: "session already finished" }, status: :gone unless session.active?
|
|
123
|
+
|
|
124
|
+
session.chat.cancel! if session.turns.where(status: "running").exists?
|
|
125
|
+
released = session.continuation_token
|
|
126
|
+
session.finish!("completed")
|
|
127
|
+
|
|
128
|
+
render json: { session_id: session.id, released_continuation_token: released }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Xeno
|
|
2
|
+
# The Slack Events API endpoint. The generic auth lambda is skipped —
|
|
3
|
+
# Slack's own request signature IS the authentication (verified in
|
|
4
|
+
# constant time), and it fails closed: no configured slack channel = 404,
|
|
5
|
+
# bad or stale signature = 401.
|
|
6
|
+
class SlackController < ApiController
|
|
7
|
+
skip_before_action :authenticate!
|
|
8
|
+
|
|
9
|
+
def events
|
|
10
|
+
channel = Channels.registry[:slack]
|
|
11
|
+
return head :not_found unless channel
|
|
12
|
+
|
|
13
|
+
raw = request.raw_post
|
|
14
|
+
unless channel.verify_signature(
|
|
15
|
+
request.headers["X-Slack-Request-Timestamp"],
|
|
16
|
+
request.headers["X-Slack-Signature"],
|
|
17
|
+
raw
|
|
18
|
+
)
|
|
19
|
+
return head :unauthorized
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
payload = JSON.parse(raw)
|
|
23
|
+
return render json: { challenge: payload["challenge"] } if payload["type"] == "url_verification"
|
|
24
|
+
|
|
25
|
+
enqueue_event(payload)
|
|
26
|
+
head :ok
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Fast ack (Slack retries anything that misses its 3s window) + retry
|
|
32
|
+
# dedup: the event_id claim and the job enqueue commit together, so a
|
|
33
|
+
# retry storm yields exactly one processing job — and a crash before
|
|
34
|
+
# the ack leaves the claim unclaimed for the retry to win. Events
|
|
35
|
+
# without an event_id (shouldn't happen for event_callback) process
|
|
36
|
+
# without dedup rather than being dropped.
|
|
37
|
+
def enqueue_event(payload)
|
|
38
|
+
event_id = payload["event_id"]
|
|
39
|
+
if event_id.present?
|
|
40
|
+
Dedup.transaction do
|
|
41
|
+
SlackEventJob.perform_later(payload) if Dedup.claim("slack_event", event_id)
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
SlackEventJob.perform_later(payload)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|