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