xeno 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -2
- data/README.md +3 -1
- data/Rakefile +2 -0
- data/app/controllers/xeno/api_controller.rb +15 -18
- data/app/controllers/xeno/application_controller.rb +2 -0
- data/app/controllers/xeno/dev_controller.rb +5 -4
- data/app/controllers/xeno/dev_ui_controller.rb +5 -4
- data/app/controllers/xeno/health_controller.rb +3 -1
- data/app/controllers/xeno/sessions_controller.rb +22 -24
- data/app/controllers/xeno/slack_controller.rb +9 -10
- data/app/controllers/xeno/streams_controller.rb +18 -22
- data/app/helpers/xeno/application_helper.rb +2 -0
- data/app/jobs/xeno/application_job.rb +2 -0
- data/app/jobs/xeno/reaper_job.rb +5 -4
- data/app/jobs/xeno/schedule_job.rb +10 -13
- data/app/jobs/xeno/slack_event_job.rb +7 -7
- data/app/jobs/xeno/turn_job.rb +5 -4
- data/app/mailers/xeno/application_mailer.rb +2 -0
- data/app/models/xeno/action.rb +7 -12
- data/app/models/xeno/application_record.rb +2 -0
- data/app/models/xeno/chat.rb +9 -11
- data/app/models/xeno/dedup.rb +7 -7
- data/app/models/xeno/event.rb +16 -20
- data/app/models/xeno/message.rb +2 -0
- data/app/models/xeno/pending_message.rb +4 -2
- data/app/models/xeno/session.rb +52 -67
- data/app/models/xeno/turn.rb +29 -34
- data/config/routes.rb +2 -0
- data/db/migrate/{20260806000001_move_transcript_support_tables_to_ruby_llm.rb → 20260808000001_create_ruby_llm_tables.rb} +25 -54
- data/db/migrate/{20260804000002_create_xeno_orchestration_tables.rb → 20260808000002_create_xeno_tables.rb} +44 -14
- data/docs/configuration.md +56 -0
- data/docs/runtime.md +4 -4
- data/exe/xeno +1 -1
- data/lib/generators/xeno/install/install_generator.rb +3 -2
- data/lib/generators/xeno/install/templates/agent.rb +1 -2
- data/lib/generators/xeno/install/templates/initializer.rb +6 -9
- data/lib/generators/xeno/tool/tool_generator.rb +4 -2
- data/lib/tasks/xeno_tasks.rake +2 -0
- data/lib/xeno/agent_config.rb +9 -8
- data/lib/xeno/agent_definition.rb +25 -28
- data/lib/xeno/approval_context.rb +2 -0
- data/lib/xeno/arguments.rb +8 -9
- data/lib/xeno/ask_question.rb +5 -5
- data/lib/xeno/channels/slack.rb +25 -29
- data/lib/xeno/channels.rb +10 -9
- data/lib/xeno/compaction.rb +20 -33
- data/lib/xeno/configuration.rb +35 -44
- data/lib/xeno/engine.rb +8 -7
- data/lib/xeno/errors.rb +11 -10
- data/lib/xeno/hooks.rb +7 -10
- data/lib/xeno/info.rb +3 -2
- data/lib/xeno/inputs.rb +26 -19
- data/lib/xeno/reaper.rb +13 -17
- data/lib/xeno/schedules.rb +8 -9
- data/lib/xeno/session_state.rb +8 -9
- data/lib/xeno/standalone/local_secret.rb +7 -6
- data/lib/xeno/standalone/model_refresh.rb +6 -6
- data/lib/xeno/standalone/puma.rb +9 -9
- data/lib/xeno/standalone.rb +22 -21
- data/lib/xeno/tool.rb +28 -25
- data/lib/xeno/turn_runner.rb +74 -96
- data/lib/xeno/version.rb +3 -1
- data/lib/xeno.rb +16 -14
- metadata +4 -9
- data/db/migrate/20260804000001_create_xeno_llm_tables.rb +0 -70
- data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +0 -8
- data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +0 -8
- data/db/migrate/20260805000003_create_xeno_dedups.rb +0 -14
- data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +0 -9
- data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +0 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4925abe330e6c36702e33e5c9322ef3b23aa7a9ad1f98d2e2aedbfa0ae691593
|
|
4
|
+
data.tar.gz: 8f7beb0a6be6b1ebe57f90811f3c99384ab42db88583643311426c756ba99223
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5dc647886c481b5d0f19cddcca9759a73a07d354120c1e6399d40e0c3dad4a0cae94a278d5a61a6cbd6b1b82043f483a0a742b61aae4ce38a5816805c0531a6e
|
|
7
|
+
data.tar.gz: 5eae3ba1312fcaeda570024497bbbdea16a623fc6a6aff0be73ef16f4375a8509292f61ac7da67b72b08040af66d8a44f9b7e73805f9868ef2c6d33938b1f2c9
|
data/CHANGELOG.md
CHANGED
|
@@ -2,12 +2,55 @@
|
|
|
2
2
|
|
|
3
3
|
Format follows [Keep a Changelog](https://keepachangelog.com); versions follow
|
|
4
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
|
|
5
|
+
change anything.** `0.1.0` will mark the first release we consider usable in anger:
|
|
6
6
|
after RubyLLM v2 GA is absorbed and the API has held still through early-adopter
|
|
7
7
|
feedback.
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.0.2] - 2026-08-13
|
|
12
|
+
|
|
13
|
+
**Upgrading from 0.0.1 requires a fresh database.** The migrations were squashed
|
|
14
|
+
and the old stamps no longer exist, so there is no migration path forward: the
|
|
15
|
+
`0.0.x` preview line reserves exactly this.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Interop with RubyLLM's tool approvals. A tool that declares `requires_approval`
|
|
20
|
+
and no xeno `approval` policy parks for approval instead of executing ungated;
|
|
21
|
+
an explicit policy, including `:never`, still wins. Approve and deny decisions
|
|
22
|
+
also land on the standard `ruby_llm_tool_calls.approval` column, so tooling
|
|
23
|
+
built against RubyLLM's convention reads xeno sessions natively.
|
|
24
|
+
- Tools that declare a `tool_call:` keyword on `execute` receive the
|
|
25
|
+
`RubyLLM::ToolCall` they were invoked by, so a tool body can read its own
|
|
26
|
+
call id for logging or correlation. Tools without the keyword are called
|
|
27
|
+
exactly as before.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- The `ruby_llm` pin moved from `2aaddf96` to `be80b6f1` (145 commits of upstream
|
|
32
|
+
v2 development). No xeno API usage changed; the bundle swaps `ruby_llm-schema`
|
|
33
|
+
for `schematist`. The scaffold Gemfile and README point at the new ref.
|
|
34
|
+
- The engine's eight migrations are now two: `create_ruby_llm_tables` and
|
|
35
|
+
`create_xeno_tables`. The old set replayed pre-release history, creating tables
|
|
36
|
+
only to drop them again, through a database no install has ever had. The new
|
|
37
|
+
pair produces the same final schema without the churn, and each
|
|
38
|
+
`ruby_llm_*` table is skipped when the database
|
|
39
|
+
already has it, so a host app that ran ruby_llm's own installer keeps what it
|
|
40
|
+
owns.
|
|
41
|
+
- The transcript tables match upstream's current install template: tool calls
|
|
42
|
+
carry `approval`, messages carry `server_tool_calls` and `raw_content`. All
|
|
43
|
+
three are read behind column guards, so behavior is unchanged until the pinned
|
|
44
|
+
ruby_llm persists them.
|
|
45
|
+
- The `Gemfile` no longer prefers a local ruby_llm checkout when one happens to
|
|
46
|
+
sit next door. Development bundles the same pinned ref as everyone else.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- Removing an agent's instructions now takes effect at the next turn, the same
|
|
51
|
+
way editing them always has: staging clears the persisted system message when
|
|
52
|
+
nothing resolves, instead of silently keeping the stale one.
|
|
53
|
+
|
|
11
54
|
## [0.0.1] - 2026-08-07
|
|
12
55
|
|
|
13
56
|
The first public release of xeno, a framework for durable AI agents built on Rails:
|
|
@@ -28,7 +71,7 @@ The first public release of xeno, a framework for durable AI agents built on Rai
|
|
|
28
71
|
post-then-edit streaming).
|
|
29
72
|
- Markdown cron schedules compiled to Solid Queue recurring entries, with dedup.
|
|
30
73
|
- Generators (`xeno:install`, `xeno:tool`) and standalone mode: `xeno new my-agent` /
|
|
31
|
-
`xeno server`
|
|
74
|
+
`xeno server` for one process, sqlite, zero services. Scaffolds include a pinned
|
|
32
75
|
`ruby_llm` Gemfile, dotenv, and an AGENTS.md pointing coding agents at the gem docs.
|
|
33
76
|
- Standalone first boot refreshes the model registry after the boot migrations, so
|
|
34
77
|
current model ids resolve without a manual `RubyLLM.models.refresh!`.
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@ xeno is a framework for durable AI agents. Built on Rails, it runs standalone (`
|
|
|
5
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
6
|
|
|
7
7
|
> [!WARNING]
|
|
8
|
-
> xeno currently tracks RubyLLM v2 (unreleased), pinned to git ref [`
|
|
8
|
+
> xeno currently tracks RubyLLM v2 (unreleased), pinned to git ref [`be80b6f1`](https://github.com/crmne/ruby_llm/commit/be80b6f1). 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
9
|
|
|
10
10
|
## The filesystem is the authoring interface
|
|
11
11
|
|
|
@@ -155,6 +155,8 @@ Xeno.configure do |config|
|
|
|
155
155
|
end
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
+
Every knob is listed in [docs/configuration.md](docs/configuration.md).
|
|
159
|
+
|
|
158
160
|
Development with no lambda configured stays usable on localhost; everywhere else the absence of a checker is a 401.
|
|
159
161
|
|
|
160
162
|
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.
|
data/Rakefile
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# Base for every engine endpoint. Auth fails closed: without a configured
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
|
|
6
|
-
class ApiController < ActionController::API
|
|
4
|
+
# Base for every engine endpoint. Auth fails closed: without a configured authenticate lambda,
|
|
5
|
+
# everything is 401. The lambda receives the request; a falsy return is a 401; its truthy return
|
|
6
|
+
# value is the request principal (stamped onto sessions by the HTTP channel).
|
|
7
|
+
class ApiController < ActionController::API # :nodoc:
|
|
7
8
|
before_action :authenticate!
|
|
8
9
|
|
|
9
10
|
rescue_from ActiveRecord::RecordNotFound do
|
|
@@ -18,9 +19,8 @@ module Xeno
|
|
|
18
19
|
render json: { error: error.message }, status: :conflict
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
# The development fallback principal (no auth lambda configured). It
|
|
22
|
-
#
|
|
23
|
-
# localhost curl stay usable.
|
|
22
|
+
# The development fallback principal (no auth lambda configured). It matches every session — in
|
|
23
|
+
# development only — so the dev UI and localhost curl stay usable.
|
|
24
24
|
DEV_PRINCIPAL = { "user" => "dev" }.freeze
|
|
25
25
|
|
|
26
26
|
private
|
|
@@ -28,8 +28,8 @@ module Xeno
|
|
|
28
28
|
def authenticate!
|
|
29
29
|
checker = Xeno.config.authenticate
|
|
30
30
|
|
|
31
|
-
# Development with no auth configured stays usable (the dev UI, curl on
|
|
32
|
-
#
|
|
31
|
+
# Development with no auth configured stays usable (the dev UI, curl on localhost). Everywhere
|
|
32
|
+
# else the absence of a checker is a 401.
|
|
33
33
|
if checker.nil? && Rails.env.development?
|
|
34
34
|
@current_principal = DEV_PRINCIPAL
|
|
35
35
|
return
|
|
@@ -44,11 +44,9 @@ module Xeno
|
|
|
44
44
|
@current_principal == true ? nil : @current_principal
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# principals everywhere — single-tenant mode, every authenticated
|
|
51
|
-
# caller matches (documented in docs/runtime.md).
|
|
47
|
+
# A session belongs to the principal stamped at create; a non-matching caller gets the same 404
|
|
48
|
+
# as a nonexistent id (no existence leak). A lambda returning bare `true` yields nil principals
|
|
49
|
+
# everywhere — single-tenant mode, every authenticated caller matches.
|
|
52
50
|
def find_owned_session!
|
|
53
51
|
session = Session.find(params[:id])
|
|
54
52
|
raise ActiveRecord::RecordNotFound unless owns?(session)
|
|
@@ -57,9 +55,8 @@ module Xeno
|
|
|
57
55
|
end
|
|
58
56
|
|
|
59
57
|
def owns?(session)
|
|
60
|
-
# Object identity on purpose: only the fallback path above grants the
|
|
61
|
-
#
|
|
62
|
-
# hash.
|
|
58
|
+
# Object identity on purpose: only the fallback path above grants the wildcard, never a
|
|
59
|
+
# configured lambda that happens to return the same hash.
|
|
63
60
|
return true if Rails.env.development? && @current_principal.equal?(DEV_PRINCIPAL)
|
|
64
61
|
|
|
65
62
|
session.principal == current_principal.as_json
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# Development conveniences. Schedules never fire on cadence in dev; this
|
|
3
|
-
#
|
|
4
|
-
|
|
5
|
-
class DevController < ApiController
|
|
4
|
+
# Development conveniences. Schedules never fire on cadence in dev; this endpoint triggers one by
|
|
5
|
+
# name. Refuses to exist outside development (XENO_DEV_ROUTES=1 lets tests exercise it).
|
|
6
|
+
class DevController < ApiController # :nodoc:
|
|
6
7
|
before_action :ensure_development
|
|
7
8
|
|
|
8
9
|
def dispatch_schedule
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# The mounted dev chat UI: chat with the
|
|
3
|
-
#
|
|
4
|
-
|
|
5
|
-
class DevUiController < ApplicationController
|
|
4
|
+
# The mounted dev chat UI: chat with the agent, watch the event stream live (over xeno's own SSE
|
|
5
|
+
# endpoint), and approve/deny/answer parked work. Development only.
|
|
6
|
+
class DevUiController < ApplicationController # :nodoc:
|
|
6
7
|
before_action :ensure_development
|
|
7
8
|
|
|
8
9
|
def index
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# The HTTP channel: always mounted, fail-closed. The channel owns the
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# namespace — a crafted `slack:<channel>:<ts>` token would otherwise
|
|
10
|
-
# hijack which Slack thread the agent posts into.
|
|
4
|
+
# The HTTP channel: always mounted, fail-closed. The channel owns the continuation token (the
|
|
5
|
+
# resume handle): create returns one, follow-up messages must present it, terminal states release
|
|
6
|
+
# it, reset retires the session so the same token can start fresh.
|
|
7
|
+
class SessionsController < ApiController # :nodoc:
|
|
8
|
+
# POST /v1/sessions { message:, continuation_token: (optional) } Client-supplied tokens are
|
|
9
|
+
# restricted to the reserved `http:` namespace — a crafted `slack:<channel>:<ts>` token would
|
|
10
|
+
# otherwise hijack which Slack thread the agent posts into.
|
|
11
11
|
def create
|
|
12
12
|
message = params.require(:message)
|
|
13
13
|
token = params[:continuation_token].presence
|
|
@@ -31,9 +31,9 @@ module Xeno
|
|
|
31
31
|
status: :conflict
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
# POST /v1/sessions/:id/messages { message:, continuation_token:, steer: }
|
|
35
|
-
#
|
|
36
|
-
#
|
|
34
|
+
# POST /v1/sessions/:id/messages { message:, continuation_token:, steer: } steer: true stops the
|
|
35
|
+
# active turn (settled safely) and makes this message the next turn instead of queueing behind
|
|
36
|
+
# the current work.
|
|
37
37
|
def message
|
|
38
38
|
session = find_owned_session!
|
|
39
39
|
|
|
@@ -73,10 +73,9 @@ module Xeno
|
|
|
73
73
|
turn_status: action.turn.reload.status }
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
# POST /v1/sessions/:id/cancel — stop the active turn, session intact.
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
# runner's checker polls (takes effect within a chunk or step boundary).
|
|
76
|
+
# POST /v1/sessions/:id/cancel — stop the active turn, session intact. A parked/pending turn
|
|
77
|
+
# cancels immediately (no process holds it); a running turn is cancelled cooperatively via the
|
|
78
|
+
# persisted flag the runner's checker polls (takes effect within a chunk or step boundary).
|
|
80
79
|
def cancel
|
|
81
80
|
session = find_owned_session!
|
|
82
81
|
turn = session.turns.where(status: %w[pending running waiting]).order(:sequence).first
|
|
@@ -87,9 +86,9 @@ module Xeno
|
|
|
87
86
|
session.chat.cancel!
|
|
88
87
|
render json: { turn_id: turn.id, cancelling: true }, status: :accepted
|
|
89
88
|
else
|
|
90
|
-
# Settle BEFORE cancelling: a parked turn's assistant tool_calls are
|
|
91
|
-
#
|
|
92
|
-
#
|
|
89
|
+
# Settle BEFORE cancelling: a parked turn's assistant tool_calls are still unanswered, and
|
|
90
|
+
# leaving them dangling bricks the session (every later generate replays the malformed
|
|
91
|
+
# transcript).
|
|
93
92
|
session.settle_unanswered_tool_calls!(turn, reason: "cancelled by user")
|
|
94
93
|
turn.update!(status: "cancelled")
|
|
95
94
|
session.update!(status: "running") if session.status == "waiting"
|
|
@@ -98,9 +97,8 @@ module Xeno
|
|
|
98
97
|
end
|
|
99
98
|
end
|
|
100
99
|
|
|
101
|
-
# POST /v1/sessions/:id/compact —
|
|
102
|
-
#
|
|
103
|
-
# executes the moment the session is free. Never appends a user message.
|
|
100
|
+
# POST /v1/sessions/:id/compact — manual compaction. Runs as a claimed turn: queues behind
|
|
101
|
+
# active or parked work, never appends a user message.
|
|
104
102
|
def compact
|
|
105
103
|
session = find_owned_session!
|
|
106
104
|
|
|
@@ -114,8 +112,8 @@ module Xeno
|
|
|
114
112
|
render json: { session_id: session.id, turn_id: turn.id, status: "requested" }, status: :accepted
|
|
115
113
|
end
|
|
116
114
|
|
|
117
|
-
# POST /v1/sessions/:id/reset — retire the session (the /new command);
|
|
118
|
-
#
|
|
115
|
+
# POST /v1/sessions/:id/reset — retire the session (the /new command); releases the continuation
|
|
116
|
+
# token so the same handle can start fresh.
|
|
119
117
|
def reset
|
|
120
118
|
session = find_owned_session!
|
|
121
119
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# The Slack Events API endpoint. The generic auth lambda is skipped —
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
|
|
6
|
-
class SlackController < ApiController
|
|
4
|
+
# The Slack Events API endpoint. The generic auth lambda is skipped — Slack's own request
|
|
5
|
+
# signature IS the authentication (verified in constant time), and it fails closed: no configured
|
|
6
|
+
# slack channel = 404, bad or stale signature = 401.
|
|
7
|
+
class SlackController < ApiController # :nodoc:
|
|
7
8
|
skip_before_action :authenticate!
|
|
8
9
|
|
|
9
10
|
def events
|
|
@@ -28,11 +29,9 @@ module Xeno
|
|
|
28
29
|
|
|
29
30
|
private
|
|
30
31
|
|
|
31
|
-
# Fast ack (Slack retries anything
|
|
32
|
-
#
|
|
33
|
-
#
|
|
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
|
|
32
|
+
# Fast ack (Slack retries anything past its 3s window) with retry dedup: the event_id claim and
|
|
33
|
+
# the job enqueue commit together, so a retry storm yields one processing job, and a crash
|
|
34
|
+
# before the ack leaves the claim for the retry to win. Events without an event_id process
|
|
36
35
|
# without dedup rather than being dropped.
|
|
37
36
|
def enqueue_event(payload)
|
|
38
37
|
event_id = payload["event_id"]
|
|
@@ -1,14 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# The session event stream: SSE by default, NDJSON on request
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# replay history and then follow live. The stream ends when the session
|
|
7
|
-
# reaches a terminal status (or the optional configured max duration).
|
|
4
|
+
# The session event stream: SSE by default, NDJSON on request (`?format=ndjson` or `Accept:
|
|
5
|
+
# application/x-ndjson`). `?start_index=N` rewinds — events are durable rows, so clients replay
|
|
6
|
+
# history and then follow live. The stream ends at a terminal session status or the configured max
|
|
7
|
+
# duration.
|
|
8
8
|
#
|
|
9
|
-
# Known limitation
|
|
10
|
-
|
|
11
|
-
class StreamsController < ApiController
|
|
9
|
+
# Known limitation: ActionController::Live holds a thread per client.
|
|
10
|
+
class StreamsController < ApiController # :nodoc:
|
|
12
11
|
include ActionController::Live
|
|
13
12
|
|
|
14
13
|
KEEPALIVE_INTERVAL = 5 # seconds without a write before a ping
|
|
@@ -26,8 +25,8 @@ module Xeno
|
|
|
26
25
|
def close = @sse.close
|
|
27
26
|
end
|
|
28
27
|
|
|
29
|
-
# NDJSON framing: one envelope per line (type/index live inside the
|
|
30
|
-
#
|
|
28
|
+
# NDJSON framing: one envelope per line (type/index live inside the envelope); a bare newline as
|
|
29
|
+
# keepalive — line parsers skip empties.
|
|
31
30
|
class NdjsonWriter
|
|
32
31
|
def initialize(stream)
|
|
33
32
|
@stream = stream
|
|
@@ -58,21 +57,19 @@ module Xeno
|
|
|
58
57
|
end
|
|
59
58
|
last_write = Process.clock_gettime(Process::CLOCK_MONOTONIC) if events.any?
|
|
60
59
|
|
|
61
|
-
# A full batch means more history is waiting — keep paging through
|
|
62
|
-
#
|
|
60
|
+
# A full batch means more history is waiting — keep paging through the catch-up without
|
|
61
|
+
# sleeping or ending on a terminal status.
|
|
63
62
|
next if events.size == batch_limit
|
|
64
63
|
|
|
65
64
|
break unless session.reload.active?
|
|
66
65
|
break if stream_expired?(started)
|
|
67
|
-
# A graceful stop must not wait out the in-flight-request window
|
|
68
|
-
# the
|
|
69
|
-
# client reconnects with its cursor), so close it and let the
|
|
70
|
-
# server exit. The 5s force cap in the puma config stays as the
|
|
66
|
+
# A graceful stop must not wait out the in-flight-request window. The stream is resumable by
|
|
67
|
+
# design, so close it and let the server exit; the 5s force cap in the puma config is the
|
|
71
68
|
# backstop.
|
|
72
69
|
break if server_shutting_down?
|
|
73
70
|
|
|
74
|
-
# A quiet stream never writes, so a dead client would never raise and
|
|
75
|
-
#
|
|
71
|
+
# A quiet stream never writes, so a dead client would never raise and this thread would poll
|
|
72
|
+
# forever — keepalives make disconnects visible.
|
|
76
73
|
if Process.clock_gettime(Process::CLOCK_MONOTONIC) - last_write > KEEPALIVE_INTERVAL
|
|
77
74
|
writer.keepalive
|
|
78
75
|
last_write = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -110,9 +107,8 @@ module Xeno
|
|
|
110
107
|
false
|
|
111
108
|
end
|
|
112
109
|
|
|
113
|
-
# ActionController::Live runs the action on its
|
|
114
|
-
#
|
|
115
|
-
# server). Find the process's server instance once per connection.
|
|
110
|
+
# ActionController::Live runs the action on its own thread, so Puma's thread-local
|
|
111
|
+
# Server.current is nil here. Find the process's server instance once per connection.
|
|
116
112
|
def puma_server
|
|
117
113
|
return nil unless defined?(Puma::Server)
|
|
118
114
|
|
data/app/jobs/xeno/reaper_job.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# Periodic sweep for turns with no live owner (see Xeno::Reaper). Fired
|
|
3
|
-
#
|
|
4
|
-
|
|
5
|
-
class ReaperJob < ApplicationJob
|
|
4
|
+
# Periodic sweep for turns with no live owner (see Xeno::Reaper). Fired by Solid Queue's recurring
|
|
5
|
+
# machinery, a cron hitting `xeno:reap`, or any other scheduler — running it twice is harmless.
|
|
6
|
+
class ReaperJob < ApplicationJob # :nodoc:
|
|
6
7
|
queue_as :default
|
|
7
8
|
|
|
8
9
|
def perform
|
|
@@ -1,15 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# Fires one scheduled run: a fire-and-forget task-mode session under the
|
|
3
|
-
#
|
|
4
|
-
# and the dev dispatch endpoint both enqueue this.
|
|
4
|
+
# Fires one scheduled run: a fire-and-forget task-mode session under the app principal, enqueued
|
|
5
|
+
# by Solid Queue recurring entries or the dev dispatch endpoint.
|
|
5
6
|
#
|
|
6
|
-
# At-least-once safe: queues redeliver, and
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
# never double-open — it re-enqueues the recorded turn instead (idle
|
|
11
|
-
# no-op if the first delivery finished: the claim CAS decides).
|
|
12
|
-
class ScheduleJob < ApplicationJob
|
|
7
|
+
# At-least-once safe: queues redeliver, and recurring dedup covers only the enqueue. The dedup row
|
|
8
|
+
# claims this job_id in the same transaction that opens the session, so a redelivered tick can
|
|
9
|
+
# never double-open — it re-enqueues the recorded turn instead.
|
|
10
|
+
class ScheduleJob < ApplicationJob # :nodoc:
|
|
13
11
|
queue_as :default
|
|
14
12
|
|
|
15
13
|
discard_on ArgumentError
|
|
@@ -43,9 +41,8 @@ module Xeno
|
|
|
43
41
|
|
|
44
42
|
private
|
|
45
43
|
|
|
46
|
-
# This job_id already ran (or died mid-run): pick up ITS turn instead of
|
|
47
|
-
#
|
|
48
|
-
# first delivery actually finished.
|
|
44
|
+
# This job_id already ran (or died mid-run): pick up ITS turn instead of opening a second
|
|
45
|
+
# session. The claim CAS makes this a no-op when the first delivery actually finished.
|
|
49
46
|
def redelivered_turn(claimed)
|
|
50
47
|
return nil if claimed
|
|
51
48
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# Processes one verified Slack event off the webhook path
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
# thread's continuation token is unique among active sessions.
|
|
8
|
-
class SlackEventJob < ApplicationJob
|
|
4
|
+
# Processes one verified Slack event off the webhook path: the endpoint acks inside Slack's
|
|
5
|
+
# 3-second window and hands the payload here. Retry storms die at the event_id dedup row, and a
|
|
6
|
+
# session can never be double-opened — the thread's continuation token is unique among active
|
|
7
|
+
# sessions.
|
|
8
|
+
class SlackEventJob < ApplicationJob # :nodoc:
|
|
9
9
|
queue_as :default
|
|
10
10
|
|
|
11
11
|
discard_on ActiveJob::DeserializationError
|
data/app/jobs/xeno/turn_job.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# One turn = one job. All correctness (claim, fencing, replay) lives in
|
|
3
|
-
#
|
|
4
|
-
|
|
5
|
-
class TurnJob < ApplicationJob
|
|
4
|
+
# One turn = one job. All correctness (claim, fencing, replay) lives in TurnRunner + the database;
|
|
5
|
+
# the queue only provides at-least-once delivery and retry backoff.
|
|
6
|
+
class TurnJob < ApplicationJob # :nodoc:
|
|
6
7
|
queue_as :default
|
|
7
8
|
|
|
8
9
|
retry_on StandardError, wait: :polynomially_longer, attempts: 5
|
data/app/models/xeno/action.rb
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# One requested tool call: the durability checkpoint and
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# logic can trust either signal.
|
|
4
|
+
# One requested tool call: the durability checkpoint and the approval state. A completed action
|
|
5
|
+
# means executed and its result is in the transcript — committed in one transaction, so replay can
|
|
6
|
+
# trust either signal.
|
|
6
7
|
class Action < ApplicationRecord
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
enum :status, %w[pending pending_approval approved denied completed failed].index_with(&:itself), validate: true
|
|
9
|
+
enum :kind, %w[tool question].index_with(&:itself), prefix: true, validate: true
|
|
9
10
|
|
|
10
11
|
belongs_to :turn, class_name: "Xeno::Turn"
|
|
11
12
|
|
|
12
|
-
validates :status, inclusion: { in: STATUSES }
|
|
13
|
-
validates :kind, inclusion: { in: KINDS }
|
|
14
|
-
|
|
15
13
|
def self.record!(turn, tool_call, kind: "tool")
|
|
16
14
|
find_or_create_by!(turn: turn, tool_call_id: tool_call.id) do |action|
|
|
17
15
|
action.tool_name = tool_call.name
|
|
@@ -19,8 +17,5 @@ module Xeno
|
|
|
19
17
|
action.input = tool_call.arguments
|
|
20
18
|
end
|
|
21
19
|
end
|
|
22
|
-
|
|
23
|
-
def completed? = status == "completed"
|
|
24
|
-
def awaiting_input? = %w[pending_approval].include?(status)
|
|
25
20
|
end
|
|
26
21
|
end
|
data/app/models/xeno/chat.rb
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# The persisted RubyLLM chat backing one session's transcript
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# engine namespace. Models, tool calls, usage, and batches are RubyLLM
|
|
6
|
-
# library records (ruby_llm_* tables), polymorphic toward this class.
|
|
4
|
+
# The persisted RubyLLM chat backing one session's transcript, owned by xeno — host apps with
|
|
5
|
+
# their own acts_as chat classes are unaffected. Models, tool calls, usage, and batches are
|
|
6
|
+
# RubyLLM library records (ruby_llm_* tables), polymorphic toward this class.
|
|
7
7
|
class Chat < ApplicationRecord
|
|
8
8
|
acts_as_chat message_class: "Xeno::Message"
|
|
9
9
|
|
|
10
|
-
# assume_model_exists and protocol are runtime attributes, not columns
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# message of any session using a custom/OpenAI-compatible model
|
|
15
|
-
# (finding K). Every entry point re-applies them from the definition.
|
|
10
|
+
# assume_model_exists and protocol are runtime attributes, not columns, so a freshly loaded
|
|
11
|
+
# record loses them and the first to_llm build resolves the model strictly — ModelNotFoundError
|
|
12
|
+
# on the session's second message for a custom model. Every entry point re-applies them from the
|
|
13
|
+
# definition.
|
|
16
14
|
def apply_runtime_options!(model_options)
|
|
17
15
|
self.assume_model_exists = model_options[:assume_model_exists] if model_options.key?(:assume_model_exists)
|
|
18
16
|
self.protocol = model_options[:protocol] if model_options.key?(:protocol)
|
data/app/models/xeno/dedup.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# The at-least-once dedup ledger. Redelivered work (a cron tick fired
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# recorded in metadata.
|
|
4
|
+
# The at-least-once dedup ledger. Redelivered work (a cron tick fired twice, a Slack retry storm)
|
|
5
|
+
# claims its (scope, key) once; every other delivery loses the unique-index race and can read what
|
|
6
|
+
# the winner recorded in metadata.
|
|
6
7
|
class Dedup < ApplicationRecord
|
|
7
8
|
validates :scope, :key, presence: true
|
|
8
9
|
|
|
9
|
-
# Returns the freshly claimed row, or nil when the key was already
|
|
10
|
-
#
|
|
11
|
-
# concurrency (the unique index decides).
|
|
10
|
+
# Returns the freshly claimed row, or nil when the key was already claimed. Safe inside
|
|
11
|
+
# enclosing transactions (savepoint), safe under concurrency (the unique index decides).
|
|
12
12
|
def self.claim(scope, key, metadata: {})
|
|
13
13
|
transaction(requires_new: true) do
|
|
14
14
|
create!(scope: scope, key: key, metadata: metadata)
|