xeno 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +38 -0
  3. data/LICENSE +21 -0
  4. data/README.md +211 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/xeno/application.css +15 -0
  7. data/app/controllers/xeno/api_controller.rb +68 -0
  8. data/app/controllers/xeno/application_controller.rb +4 -0
  9. data/app/controllers/xeno/dev_controller.rb +24 -0
  10. data/app/controllers/xeno/dev_ui_controller.rb +71 -0
  11. data/app/controllers/xeno/health_controller.rb +10 -0
  12. data/app/controllers/xeno/sessions_controller.rb +131 -0
  13. data/app/controllers/xeno/slack_controller.rb +48 -0
  14. data/app/controllers/xeno/streams_controller.rb +122 -0
  15. data/app/helpers/xeno/application_helper.rb +4 -0
  16. data/app/jobs/xeno/application_job.rb +4 -0
  17. data/app/jobs/xeno/reaper_job.rb +12 -0
  18. data/app/jobs/xeno/schedule_job.rb +56 -0
  19. data/app/jobs/xeno/slack_event_job.rb +20 -0
  20. data/app/jobs/xeno/turn_job.rb +16 -0
  21. data/app/mailers/xeno/application_mailer.rb +6 -0
  22. data/app/models/xeno/action.rb +26 -0
  23. data/app/models/xeno/application_record.rb +5 -0
  24. data/app/models/xeno/chat.rb +22 -0
  25. data/app/models/xeno/dedup.rb +24 -0
  26. data/app/models/xeno/event.rb +63 -0
  27. data/app/models/xeno/message.rb +5 -0
  28. data/app/models/xeno/pending_message.rb +7 -0
  29. data/app/models/xeno/session.rb +231 -0
  30. data/app/models/xeno/turn.rb +125 -0
  31. data/app/views/layouts/xeno/application.html.erb +18 -0
  32. data/app/views/xeno/dev_ui/_styles.html.erb +24 -0
  33. data/app/views/xeno/dev_ui/index.html.erb +28 -0
  34. data/app/views/xeno/dev_ui/show.html.erb +115 -0
  35. data/config/routes.rb +25 -0
  36. data/db/migrate/20260804000001_create_xeno_llm_tables.rb +70 -0
  37. data/db/migrate/20260804000002_create_xeno_orchestration_tables.rb +70 -0
  38. data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +8 -0
  39. data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +8 -0
  40. data/db/migrate/20260805000003_create_xeno_dedups.rb +14 -0
  41. data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +9 -0
  42. data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +8 -0
  43. data/db/migrate/20260806000001_move_transcript_support_tables_to_ruby_llm.rb +133 -0
  44. data/docs/runtime.md +275 -0
  45. data/exe/xeno +133 -0
  46. data/lib/generators/xeno/install/install_generator.rb +51 -0
  47. data/lib/generators/xeno/install/templates/agent.rb +4 -0
  48. data/lib/generators/xeno/install/templates/initializer.rb +20 -0
  49. data/lib/generators/xeno/install/templates/instructions.md +6 -0
  50. data/lib/generators/xeno/tool/templates/tool.rb.tt +16 -0
  51. data/lib/generators/xeno/tool/tool_generator.rb +13 -0
  52. data/lib/tasks/xeno_tasks.rake +24 -0
  53. data/lib/xeno/agent_config.rb +66 -0
  54. data/lib/xeno/agent_definition.rb +286 -0
  55. data/lib/xeno/approval_context.rb +4 -0
  56. data/lib/xeno/arguments.rb +62 -0
  57. data/lib/xeno/ask_question.rb +18 -0
  58. data/lib/xeno/channels/slack.rb +311 -0
  59. data/lib/xeno/channels.rb +68 -0
  60. data/lib/xeno/compaction.rb +165 -0
  61. data/lib/xeno/configuration.rb +118 -0
  62. data/lib/xeno/engine.rb +29 -0
  63. data/lib/xeno/errors.rb +40 -0
  64. data/lib/xeno/hooks.rb +37 -0
  65. data/lib/xeno/info.rb +75 -0
  66. data/lib/xeno/inputs.rb +78 -0
  67. data/lib/xeno/reaper.rb +52 -0
  68. data/lib/xeno/schedules.rb +49 -0
  69. data/lib/xeno/session_state.rb +57 -0
  70. data/lib/xeno/standalone/local_secret.rb +26 -0
  71. data/lib/xeno/standalone/model_refresh.rb +26 -0
  72. data/lib/xeno/standalone/puma.rb +17 -0
  73. data/lib/xeno/standalone.rb +136 -0
  74. data/lib/xeno/tool.rb +73 -0
  75. data/lib/xeno/turn_runner.rb +545 -0
  76. data/lib/xeno/version.rb +3 -0
  77. data/lib/xeno.rb +117 -0
  78. metadata +151 -0
data/docs/runtime.md ADDED
@@ -0,0 +1,275 @@
1
+ # The runtime, precisely
2
+
3
+ This document is shipped inside the gem so coding agents (and curious humans) can read
4
+ how the durability machinery actually works. The README sells it; this explains it.
5
+
6
+ ## Objects
7
+
8
+ - **Session** (`xeno_sessions`) — the durable conversation. Statuses:
9
+ `running | waiting | completed | failed`. Owns one transcript chat, the turn ledger,
10
+ the event stream, and at most one `continuation_token` (channel-owned resume handle,
11
+ unique among ACTIVE sessions). A session stays active indefinitely — turns finishing
12
+ never retire it; **`reset` is the only terminal transition in v0.1**, and it nulls
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 in v0.1).
15
+ - **Turn** (`xeno_turns`) — one user message (or several, when queued messages fold)
16
+ and all work until the agent responds. One ActiveJob. Statuses:
17
+ `pending | running | waiting | completed | failed | cancelled`.
18
+ - **Action** (`xeno_actions`) — one requested tool call: the durability checkpoint and
19
+ the approval state. `completed` means "executed AND its result row is in the
20
+ transcript" — both commit in one transaction.
21
+ - **Event** (`xeno_events`) — append-only, per-session dense `index` starting at 0.
22
+ The SSE cursor and the audit log are the same rows.
23
+ - **Transcript** — RubyLLM's acts_as integration: xeno owns the chat and message
24
+ rows (`xeno_chats`, `xeno_messages`); RubyLLM owns tool calls, the model
25
+ registry, per-attempt usage, and batches as library records on its own tables
26
+ (`ruby_llm_tool_calls`, `ruby_llm_models`, `ruby_llm_usage_entries`,
27
+ `ruby_llm_batches`), polymorphic toward xeno's classes. No duplication above them.
28
+
29
+ ## The turn loop (TurnRunner)
30
+
31
+ ```
32
+ claim! (CAS) → clean crash artifacts → loop:
33
+ unanswered tool calls? → replay recorded / inject denials / gate / execute
34
+ complete? → finish
35
+ otherwise → generate (one model call, step budget, heartbeat)
36
+ ```
37
+
38
+ Rules inherited from the RubyLLM v2 investigation (each empirically verified):
39
+
40
+ 1. Never `complete` (unbounded) or `run_tools` (re-executes everything on retry). The
41
+ runner IS the loop.
42
+ 2. A killed process leaves a blank assistant row (`before_message` persists it before
43
+ the HTTP call) that makes `complete?` read true — trailing blank assistant rows are
44
+ deleted on entry.
45
+ 3. Tool results are injected via Rails `add_message` (safe: tool rows carry no
46
+ thinking); assistant messages are never injected that way (it drops the thinking
47
+ signature — write columns directly if you ever must).
48
+ 4. `assume_model_exists` and `protocol` are not persisted; the runner re-applies them
49
+ from the definition on a pristine record every run.
50
+
51
+ ## Claims, fencing, ordering
52
+
53
+ - **Claim** — single-statement CAS: bump `claim_token` where
54
+ `status='pending' OR (running AND heartbeat stale)`, and no earlier non-terminal
55
+ turn exists in the session (turns run in order). One row updated = you own it.
56
+ - **Heartbeat** — touched at step boundaries, and kept alive DURING model calls and
57
+ tool bodies by a sibling beater thread (`config.heartbeat_interval`, default a
58
+ quarter of `turn_stale_after`) — a live-but-slow call is never reclaimed
59
+ mid-flight. `Xeno.config.turn_stale_after` (default 5 min) is the dead-worker
60
+ takeover window.
61
+ - **Graceful shutdown** — between steps the runner probes
62
+ `config.stopping_check` (default: `queue_adapter.stopping?`, which Solid Queue
63
+ flips when a worker begins stopping). A stopping worker releases the claim
64
+ (status back to `pending`, no `attempts` bump) and re-enqueues the turn, so a
65
+ deploy resumes on the next worker within seconds instead of waiting out the
66
+ staleness window.
67
+ - **Fencing** — **turn-row writes** (status transitions, heartbeats, the step budget)
68
+ are `WHERE claim_token = mine`; a reaped zombie fails there and aborts via
69
+ `Xeno::Fenced`. Event/action/transcript writes are NOT individually fenced — a
70
+ zombie that dies mid-step can leave a duplicate event or an extra tool-result row,
71
+ which replay and `(session, index)` dedupe absorb. No fencing probe runs inside a
72
+ model call; a fenced-out runner notices at the next turn-row write.
73
+ - **Poison guard** — `attempts >= max_turn_attempts` fails the turn at claim time.
74
+ `attempts` counts failures only (transient errors, crash reclaims); approval
75
+ resumes are counted apart (`resumes`) and are unbounded.
76
+ - **Reaper** — `Xeno::Reaper.sweep!` (ReaperJob, every minute via Solid Queue
77
+ recurring; `bin/rails xeno:reap` on other backends) re-enqueues turns with no live
78
+ owner: stale `running` (dead worker, exhausted queue retries), stale `pending`
79
+ (lost job), and `waiting` turns whose every action is resolved (a crash between
80
+ resolve and resume-enqueue). Genuinely parked turns are never touched; duplicates
81
+ are harmless because the claim CAS decides ownership.
82
+ - **Chaining** — queued messages drain into ONE staged next turn when the session
83
+ parks or the turn goes terminal (and immediately for messages sent to an
84
+ already-parked session). Drained turns DEFER their transcript: the user rows are
85
+ written when the runner claims the turn (append + fenced flag flip in one
86
+ transaction — exactly-once), so a parked turn's unanswered tool calls are never
87
+ separated from their results. A terminal turn enqueues the next pending turn
88
+ (later turns can't claim past an active earlier one).
89
+
90
+ ## Parking
91
+
92
+ A blocking action (unapproved gated tool, unanswered `ask_question`) flips the action
93
+ to `pending_approval`, emits `input.requested`, sets turn + session to `waiting`, and
94
+ the job returns. Nothing waits. `Xeno::Inputs.approve!/deny!/answer!` stamp
95
+ `resolved_at/resolved_by` and enqueue the resume — only once no blocker remains
96
+ (resuming with partial tool results is an invalid provider state). Resolution, the
97
+ status flip, and the resume enqueue commit in ONE transaction — on a DB-backed queue
98
+ (Solid Queue) a crash can never separate "approved" from "job exists"; on other
99
+ backends the reaper's sweeps recover the orphan.
100
+
101
+ Ungated calls in a mixed batch execute (and are recorded) before the park; the resume
102
+ replays them for free. Schedule sessions never park — a blocker deterministically
103
+ fails the turn (`schedule runs cannot wait for human input`).
104
+
105
+ Cancellation is cooperative: the persisted `cancelled` flag is polled by RubyLLM's
106
+ checker at loop boundaries — and since the runner does NOT stream, a cancel
107
+ takes effect at the next STEP boundary, which can mean waiting out an in-flight model
108
+ call. A parked turn cancels instantly (its dangling tool calls are settled with
109
+ denial results first, so the session keeps chatting).
110
+
111
+ **Steering** (`steer: true` on the messages endpoint, the dev UI's checkbox, or
112
+ `Session#steer!`): stop the active turn and make THIS message the next one. A parked
113
+ or pending turn settles (recorded answers injected, unapproved gates denied) and
114
+ cancels immediately, and the steer message stages at once; a running turn cancels
115
+ cooperatively with the steer message queued — the cancel path folds it into the next
116
+ turn. If the cancel loses the race with a completing turn, steering degrades to a
117
+ normal follow-up. A steered-away pending turn keeps its unsent messages in
118
+ `turns.user_message` for audit.
119
+
120
+ ## Events
121
+
122
+ Envelope: `{ type, data, meta: { index, at } }`. Emission is race-safe (unique index +
123
+ retry). Re-emission after a replayed step appends new indexes — consumers dedupe by
124
+ `(session, index)`. Vocabulary:
125
+
126
+ ```
127
+ session.started turn.started message.received
128
+ step.started step.completed step.failed
129
+ actions.requested action.result input.requested
130
+ reasoning.completed message.completed
131
+ compaction.requested compaction.completed
132
+ budget.exceeded
133
+ turn.completed turn.failed turn.cancelled
134
+ session.waiting session.completed session.failed
135
+ ```
136
+
137
+ Notes: `step.failed` fires when a tool raises (the error becomes the action result);
138
+ `session.completed` fires on `reset` (the only session-terminal transition);
139
+ `session.failed` is reserved — nothing emits it yet. Live streaming deltas are not
140
+ part of this release — `message.completed` is the durable completion signal.
141
+ `compaction.requested` fires when a compaction turn is staged (auto: with
142
+ `used`/`limit`; manual: reason only); `compaction.completed` commits atomically with
143
+ the rewritten transcript and carries `compacted_messages` (0 = nothing to compact).
144
+
145
+ ## Hooks
146
+
147
+ `agent/hooks/*.rb` — observe-only handlers for stream events:
148
+
149
+ ```ruby
150
+ Xeno.hook "turn.completed" do |event|
151
+ Metrics.increment("agent.turns") # event: event_type, data, index, session
152
+ end
153
+
154
+ Xeno.hook "*" do |event| ... end # every event
155
+ ```
156
+
157
+ Handlers fire AFTER the event row commits (never inside the emitting
158
+ transaction), typed handlers before the wildcard. They are observe-only: return
159
+ values are ignored, nothing can be vetoed, no model context can be injected, and
160
+ a raising handler is logged and skipped — a hook can never break the runtime
161
+ or fail the turn.
162
+
163
+ **Hooks are at-least-once.** Replayed steps re-emit new events with new indexes.
164
+ Key once-per-step side effects on `(turn_id, step)` from the event data; key
165
+ stored content on `(session_id, index)`.
166
+
167
+ ## Budgets
168
+
169
+ Per-session token caps, enforced BEFORE each model call from the persisted
170
+ per-attempt usage ledger (`ruby_llm_usage_entries` — the provider's own counts,
171
+ summed across every attempt of the session's chat, retries included). Input and
172
+ output are checked independently; the call that crosses a cap is allowed to
173
+ finish — the NEXT call trips, raising a deterministic `turn.failed` preceded by a
174
+ `budget.exceeded` event (`axis`, `used`, `limit`). The session stays `running`
175
+ and later turns fail the same way until `reset` retires it (a fresh session has
176
+ fresh usage).
177
+
178
+ The ledger survives compaction: compacting a session shrinks its context, never
179
+ its counted spend. (Before the ledger, usage lived on message rows and compaction
180
+ destroyed them — sessions could compact their way back under a budget. That was
181
+ accidental; cost already incurred stays counted.)
182
+
183
+ Configuration: global `config.max_input_tokens_per_session` /
184
+ `config.max_output_tokens_per_session` (nil = unlimited); per-agent override in
185
+ agent.rb via `limits input_tokens:, output_tokens:` (`false` disables an axis
186
+ even when a global cap exists). The compaction summarizer's own call is not chat
187
+ usage and does not count against the caps.
188
+
189
+ ## Session state
190
+
191
+ `Session#state` (and `state` inside any `Xeno::Tool` body) is the per-session KV
192
+ store: `get`/`[]`, `update` (locked merge), `set`, `delete`, `to_h`. Values are
193
+ JSON-typed — symbols stringify, unserializable values raise `Xeno::Error` before
194
+ anything is written. Reads always hit the database, so a tool running in a fresh
195
+ worker sees what the last worker wrote; state survives restarts and `kill -9`
196
+ because it is just a column (`xeno_sessions.state`). It never crosses sessions and
197
+ `reset` clears it.
198
+
199
+ **This is working state for one conversation, not long-term memory.** Anything
200
+ that should outlive the session (user preferences, learned facts) belongs in your
201
+ own models — a tool can read and write those directly.
202
+
203
+ ## Instructions
204
+
205
+ The system prompt refreshes at every turn stage (deploy semantics — the next turn
206
+ picks up edits). Two composable sources under `agent/`:
207
+
208
+ - `instructions.md` — the static always-on prompt.
209
+ - `instructions.rb` — `Xeno.instructions do |context| … end`, resolved fresh at
210
+ every turn stage with `context.session` and `context.principal` (the
211
+ current_user use case). The block's return is appended after the markdown,
212
+ separated by a blank line. Either file alone is sufficient; both compose.
213
+ A block that raises at resolve time logs a warning and the turn proceeds on the
214
+ static instructions — dynamic instructions can degrade, never brick. Keep the
215
+ block fast: it runs on every turn.
216
+
217
+ ## Compaction
218
+
219
+ Long transcripts are compacted **between turns** by summarize-and-replace:
220
+
221
+ - **Trigger** — after a message turn completes, when the last model call's persisted
222
+ usage (`input_tokens + output_tokens` of the newest succeeded ledger entry) crosses
223
+ `compaction_threshold` (default 0.9) × the model's context window. The window comes
224
+ from the model registry; for models the registry doesn't know
225
+ (`assume_model_exists`) set `config.compaction_context_window` or automatic
226
+ compaction never triggers. `compaction_threshold = nil` disables auto entirely.
227
+ Manual trigger: `POST /v1/sessions/:id/compact` (202; 409 if one is already
228
+ queued) or the dev UI's "Compact transcript" button.
229
+ - **Execution** — compaction runs as a turn (`kind: "compaction"`) under a normal
230
+ claim: it is exclusive by CAS, queues behind an active or parked turn via session
231
+ ordering, heartbeats through the summary model call, and replays crash-safely (the
232
+ applied result is recorded on the turn row; a replay past the commit is a no-op).
233
+ It never appends a synthetic user message.
234
+ - **Shape** — the system prompt survives; the newest `compaction_tail_turns`
235
+ (default 2) user-anchored turns survive verbatim (the cut lands on a user row, so
236
+ tool calls are never separated from their results); everything earlier is
237
+ summarized by a throwaway call to the active model and REPLACED — the summary is
238
+ written into the earliest compacted row (id/created_at ordering both keep it in
239
+ place) as a `[Conversation summary — …]` user row.
240
+ - **Caveats** — the summary call sends the compacted span verbatim (no per-message
241
+ truncation), so pathologically large single messages can stress the summarizer's
242
+ own window. Compacted content is gone from the transcript; the events and action
243
+ rows remain the audit trail.
244
+
245
+ Instrumentation spans: `xeno.turn`, `xeno.step`, `xeno.action`
246
+ (ActiveSupport::Notifications).
247
+
248
+ ## Authorization
249
+
250
+ Authentication (the configured lambda) answers "who is this"; **ownership** answers
251
+ "whose session is this". The rule:
252
+
253
+ - A session belongs to the **principal stamped at create** (the lambda's return value
254
+ for HTTP, the Slack sender for Slack, the app principal for schedules).
255
+ - Every session-scoped endpoint (`messages`, `inputs`, `cancel`, `reset`, `stream`)
256
+ compares the caller's principal to the stored one; a mismatch is the same **404** as
257
+ a nonexistent id — existence is not leaked.
258
+ - A lambda that returns bare `true` yields nil principals everywhere: **single-tenant
259
+ mode**, every authenticated caller matches every session. Return a real identity to
260
+ get per-principal isolation.
261
+ - Development with no lambda configured uses a fallback dev principal that matches
262
+ everything — development only, and only via the fallback (a configured lambda is
263
+ always enforced).
264
+
265
+ Continuation tokens are compared timing-safely. Client-supplied tokens on create are
266
+ restricted to the reserved `http:` namespace — channel namespaces (`slack:…`) are
267
+ minted only by their channels, so a crafted token cannot hijack another channel's
268
+ thread.
269
+
270
+ ## Channels
271
+
272
+ `Xeno::Channels` maps `session.channel` to a registered channel object with two
273
+ delivery hooks — `deliver_completion(session, content)` at turn completion and
274
+ `deliver_input_request(session, actions)` at park. Delivery failures log and move on:
275
+ the durable truth is rows, and the event stream replays everything.
data/exe/xeno ADDED
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env ruby
2
+ # The standalone CLI: `xeno new my-agent` scaffolds a four-file app whose
3
+ # Rails lives inside the gem; `xeno server` runs it (web + Solid Queue in
4
+ # one Puma process); `xeno info` prints the resolved agent.
5
+ require "fileutils"
6
+
7
+ def usage
8
+ puts <<~USAGE
9
+ usage:
10
+ xeno new NAME scaffold a standalone agent app
11
+ xeno server run the app in this directory (web + jobs, one process)
12
+ xeno info print the resolved agent
13
+ USAGE
14
+ exit 1
15
+ end
16
+
17
+ case ARGV.shift
18
+ when "new"
19
+ name = ARGV.shift or usage
20
+ root = File.expand_path(name)
21
+ abort "#{name} already exists" if File.exist?(root)
22
+
23
+ FileUtils.mkdir_p(File.join(root, "agent", "tools"))
24
+
25
+ File.write(File.join(root, "Gemfile"), <<~GEMFILE)
26
+ source "https://rubygems.org"
27
+
28
+ gem "xeno"
29
+ # RubyLLM v2 is unreleased and the published 1.16.0 gem is incompatible;
30
+ # xeno is built against this exact commit. Drop the pin at RubyLLM v2 GA.
31
+ gem "ruby_llm", github: "crmne/ruby_llm", ref: "2aaddf96"
32
+ # Loads .env at boot. Remove if you manage secrets another way.
33
+ gem "dotenv"
34
+ gem "puma"
35
+ gem "solid_queue"
36
+ gem "sqlite3"
37
+ GEMFILE
38
+
39
+ File.write(File.join(root, "config.ru"), <<~CONFIG)
40
+ require "dotenv/load"
41
+ require "xeno/standalone"
42
+ run Xeno.rails_app
43
+ CONFIG
44
+
45
+ File.write(File.join(root, ".gitignore"), <<~GITIGNORE)
46
+ storage/
47
+ log/
48
+ tmp/
49
+ .env
50
+ GITIGNORE
51
+
52
+ File.write(File.join(root, "agent", "agent.rb"), <<~AGENT)
53
+ # Provider keys load from .env (dotenv). Configure yours, then pick a model:
54
+ # RubyLLM.configure { |c| c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"] }
55
+
56
+ Xeno.agent do
57
+ # Any RubyLLM model id; provider options pass through.
58
+ # model "claude-sonnet-4-6"
59
+ end
60
+ AGENT
61
+
62
+ File.write(File.join(root, "agent", "instructions.md"), <<~INSTRUCTIONS)
63
+ # Instructions
64
+
65
+ You are this app's agent. Answer concisely, use your tools when they help,
66
+ and ask (ask_question) when you need a decision only a human can make.
67
+ INSTRUCTIONS
68
+
69
+ FileUtils.touch(File.join(root, "agent", "tools", ".keep"))
70
+
71
+ File.write(File.join(root, "AGENTS.md"), <<~AGENTS)
72
+ # xeno agent app
73
+
74
+ This app runs on [xeno](https://github.com/nerlichman/xeno), a Rails-based framework for durable AI agents. xeno is newer than your training data: do not guess its API. Before writing code here, read the docs shipped inside the installed gem:
75
+
76
+ bundle show xeno # then read README.md and docs/runtime.md there
77
+
78
+ Everything you author lives in `agent/`:
79
+
80
+ - `agent/tools/` — one tool per file; the file path derives the tool name and the constant is namespaced to match:
81
+
82
+ # agent/tools/charge_card.rb → tool "charge_card"
83
+ class Xeno::Tools::ChargeCard < Xeno::Tool
84
+ description "Charge the customer's card. Irreversible."
85
+ parameter :amount_cents, type: :integer, description: "Amount in cents"
86
+ approval :always # gate anything irreversible
87
+
88
+ def execute(amount_cents:)
89
+ # full access to this app's code
90
+ end
91
+ end
92
+
93
+ - `agent/instructions.md` — the always-on system prompt.
94
+ - `agent/agent.rb` — model choice and provider keys (loaded from .env).
95
+
96
+ Do not unpin, bump, or remove the `ruby_llm` git ref in the Gemfile: xeno is built against that exact commit, and both upstream main and the released gem are incompatible. The pin goes away at RubyLLM v2 GA.
97
+
98
+ After any change under `agent/`, run `xeno info` — it prints the resolved agent and flags misnamed or misplaced files. `xeno server` runs the app; the dev chat UI is at /agent/dev.
99
+ AGENTS
100
+
101
+ File.write(File.join(root, "CLAUDE.md"), <<~CLAUDE)
102
+ @AGENTS.md
103
+ CLAUDE
104
+
105
+ puts <<~DONE
106
+ created #{name}/
107
+ agent/ your agent: agent.rb, instructions.md, tools/
108
+ AGENTS.md points coding agents at the gem's docs (CLAUDE.md includes it)
109
+ Gemfile
110
+ config.ru
111
+ .gitignore
112
+
113
+ next:
114
+ cd #{name}
115
+ bundle install
116
+ xeno server # http://localhost:3000 → /agent/dev
117
+ DONE
118
+ when "server"
119
+ abort "no config.ru here — run inside an app created with `xeno new`" unless File.exist?("config.ru")
120
+
121
+ ENV["SOLID_QUEUE_IN_PUMA"] ||= "1"
122
+ require "xeno/standalone"
123
+ exec "bundle", "exec", "puma", "-C", Xeno::Standalone.puma_config_path, "config.ru"
124
+ when "info"
125
+ abort "no config.ru here — run inside an app created with `xeno new`" unless File.exist?("config.ru")
126
+
127
+ require "xeno/standalone"
128
+ Xeno.rails_app
129
+ require "xeno/info"
130
+ puts Xeno::Info.render
131
+ else
132
+ usage
133
+ end
@@ -0,0 +1,51 @@
1
+ module Xeno
2
+ module Generators
3
+ # Mounted-mode install: initializer, engine mount, migrations, and the
4
+ # agent/ scaffold.
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ class_option :skip_migrations, type: :boolean, default: false,
9
+ desc: "Skip copying the engine migrations"
10
+
11
+ def copy_initializer
12
+ template "initializer.rb", "config/initializers/xeno.rb"
13
+ end
14
+
15
+ def mount_engine
16
+ route 'mount Xeno::Engine => "/agent"'
17
+ end
18
+
19
+ def install_migrations
20
+ rake "xeno:install:migrations" unless options[:skip_migrations]
21
+ end
22
+
23
+ def scaffold_agent
24
+ template "agent.rb", "agent/agent.rb"
25
+ template "instructions.md", "agent/instructions.md"
26
+ create_file "agent/tools/.keep", ""
27
+ end
28
+
29
+ def show_next_steps
30
+ say <<~NEXT
31
+
32
+ xeno is installed. Next steps:
33
+
34
+ 1. bin/rails db:migrate
35
+ 2. Edit agent/instructions.md (the agent's always-on system prompt)
36
+ 3. bin/rails g xeno:tool GetWeather # your first tool
37
+ 4. Set the auth check in config/initializers/xeno.rb — the HTTP
38
+ channel is fail-closed and returns 401 until you do.
39
+ 5. bin/rails xeno:info # see the resolved agent
40
+
41
+ If coding agents work in this repo, add to your AGENTS.md/CLAUDE.md:
42
+
43
+ The agent/ directory uses the xeno gem. Before changing it, read
44
+ the gem's docs: `bundle show xeno`, then README.md and
45
+ docs/runtime.md there. Never unpin the ruby_llm git ref.
46
+
47
+ NEXT
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,4 @@
1
+ Xeno.agent do
2
+ # Any RubyLLM model id; provider options pass through.
3
+ # model "claude-sonnet-4-6"
4
+ end
@@ -0,0 +1,20 @@
1
+ Xeno.configure do |config|
2
+ # HTTP auth is FAIL-CLOSED: every endpoint (except health) returns 401
3
+ # until you set this. The lambda receives the request; return something
4
+ # falsy to reject, or a truthy value to accept — whatever you return
5
+ # becomes the session's principal (visible to tools and approval lambdas).
6
+ #
7
+ # config.authenticate = ->(request) do
8
+ # token = request.headers["Authorization"]&.delete_prefix("Bearer ")
9
+ # { "user" => "api" } if ActiveSupport::SecurityUtils.secure_compare(
10
+ # token.to_s, Rails.application.credentials.xeno_api_key.to_s
11
+ # )
12
+ # end
13
+
14
+ # Per-turn model-call budget (runaway protection).
15
+ # config.max_steps = 20
16
+
17
+ # How long a silent turn is presumed alive before another worker may
18
+ # reclaim it. Keep it above your slowest model call.
19
+ # config.turn_stale_after = 5.minutes
20
+ end
@@ -0,0 +1,6 @@
1
+ # Instructions
2
+
3
+ You are this application's agent. Answer concisely, use your tools when they
4
+ help, and ask (ask_question) when you need a decision only a human can make.
5
+
6
+ <!-- This file is the always-on system prompt. Edits apply from the next turn. -->
@@ -0,0 +1,16 @@
1
+ class Xeno::Tools::<%= class_name %> < Xeno::Tool
2
+ description "TODO: one line the model reads to decide when to call this."
3
+ parameter :input, description: "TODO: describe this parameter"
4
+
5
+ # Gate anything irreversible or externally visible:
6
+ # approval :always # ask every time
7
+ # approval :once # ask once per session
8
+ # approval ->(ctx) { ctx.principal&.dig("role") != "admin" }
9
+
10
+ def execute(input:)
11
+ # Full Rails access: models, jobs, credentials. Completed calls are
12
+ # recorded and never re-run, but a call interrupted mid-execution may
13
+ # retry — keep side effects idempotent or gate them with approval.
14
+ { result: input }
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ module Xeno
2
+ module Generators
3
+ # rails g xeno:tool GetWeather → agent/tools/get_weather.rb
4
+ # (the path supplies the runtime name: get_weather)
5
+ class ToolGenerator < Rails::Generators::NamedBase
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def create_tool
9
+ template "tool.rb.tt", "agent/tools/#{file_name}.rb"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ namespace :xeno do
2
+ desc "Print the resolved agent: config, instructions, tools, diagnostics"
3
+ task info: :environment do
4
+ require "xeno/info"
5
+ puts Xeno::Info.render
6
+ end
7
+
8
+ desc "Sweep turns with no live owner back into the queue (see Xeno::Reaper)"
9
+ task reap: :environment do
10
+ rescued = Xeno::Reaper.sweep!
11
+ puts "reaper: re-enqueued #{rescued.size} turn(s)#{" (ids: #{rescued.map(&:id).join(', ')})" if rescued.any?}"
12
+ end
13
+
14
+ namespace :schedules do
15
+ desc "Compile agent/schedules/*.md into Solid Queue recurring.yml entries"
16
+ task sync: :environment do
17
+ require "xeno/schedules"
18
+ result = Xeno::Schedules.sync!
19
+ managed = result.values.flat_map(&:keys).select { |k| k.start_with?(Xeno::Schedules::MANAGED_PREFIX) }.uniq
20
+ puts "recurring.yml synced — managed entries: #{managed.sort.join(', ')} " \
21
+ "(environments: #{result.keys.join(', ')})"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,66 @@
1
+ module Xeno
2
+ # The config DSL evaluated from agent/agent.rb. Optional — defaults apply
3
+ # when the file is absent.
4
+ #
5
+ # Xeno.agent do
6
+ # model "anthropic/claude-sonnet-5"
7
+ # end
8
+ #
9
+ # Any RubyLLM chat options ride along:
10
+ #
11
+ # Xeno.agent do
12
+ # model "my-model", provider: :ollama, assume_model_exists: true
13
+ # end
14
+ #
15
+ class AgentConfig
16
+ def initialize
17
+ @model_id = nil
18
+ @model_options = {}
19
+ @limits = {}
20
+ end
21
+
22
+ # Per-agent token budgets, overriding the global config:
23
+ #
24
+ # Xeno.agent do
25
+ # limits input_tokens: 2_000_000, output_tokens: 200_000
26
+ # end
27
+ #
28
+ # `false` disables an axis even when a global cap is set; an axis left
29
+ # out inherits the global `Xeno.config.max_*_tokens_per_session`.
30
+ def limits(**caps)
31
+ unless caps.empty?
32
+ unknown = caps.keys - %i[input_tokens output_tokens]
33
+ raise ArgumentError, "unknown limit axes: #{unknown.join(', ')}" if unknown.any?
34
+
35
+ @limits = @limits.merge(caps)
36
+ end
37
+ @limits
38
+ end
39
+
40
+ # The effective cap for one axis: agent override first (false = off),
41
+ # then the global config. nil = unlimited.
42
+ def token_limit(axis, global)
43
+ return nil if @limits[axis] == false
44
+
45
+ @limits.fetch(axis, nil) || global
46
+ end
47
+
48
+ # DSL setter and reader in one: `model "id", **options` inside the block,
49
+ # `config.model` afterwards.
50
+ def model(id = nil, **options)
51
+ if id
52
+ @model_id = id
53
+ @model_options = options
54
+ end
55
+ @model_id
56
+ end
57
+
58
+ def model_options
59
+ @model_options
60
+ end
61
+
62
+ def resolved_model
63
+ @model_id || RubyLLM.config.default_model
64
+ end
65
+ end
66
+ end