ask-rails 0.2.5 → 0.11.0
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 +157 -0
- data/README.md +94 -10
- data/app/controllers/ask/rails/chat_controller.rb +308 -0
- data/app/models/ask/rails/session.rb +23 -0
- data/app/views/ask/rails/chat/index.html.erb +6 -0
- data/app/views/layouts/ask/rails/application.html.erb +521 -0
- data/config/routes.rb +17 -0
- data/lib/ask/rails/audit_log.rb +181 -0
- data/lib/ask/rails/auth.rb +22 -0
- data/lib/ask/rails/configuration.rb +53 -1
- data/lib/ask/rails/engine.rb +1 -1
- data/lib/ask/rails/environment_permissions.rb +39 -0
- data/lib/ask/rails/persistence.rb +18 -5
- data/lib/ask/rails/railtie.rb +5 -1
- data/lib/ask/rails/tool.rb +29 -0
- data/lib/ask/rails/tools/read_file.rb +1 -4
- data/lib/ask/rails/tools/read_routes.rb +1 -4
- data/lib/ask/rails/tools/route_inspector.rb +69 -0
- data/lib/ask/rails/tools/run_command.rb +39 -1
- data/lib/ask/rails/tools/schema_graph.rb +174 -0
- data/lib/ask/rails/tools/search_codebase.rb +6 -7
- data/lib/ask/rails/version.rb +1 -1
- data/lib/ask/rails.rb +111 -4
- data/lib/generators/ask/rails/install/install_generator.rb +5 -0
- data/lib/generators/ask/rails/install/templates/audit_log_migration.rb +22 -0
- data/lib/generators/ask/rails/install/templates/initializer.rb +1 -1
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 862c3a8f40c0b2a6d720ab247ce87caecdfc9e5c9242c1daa386eb09d23fe300
|
|
4
|
+
data.tar.gz: 942cfdfd66cbc0e36ed56e1bfd16009226d12244cfa558e75facc99ace1c3781
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0e769e86dec70f2a4533f0de007c00e32f63c8d270d2929b29e503c22fa5b4bf780c2464be5b65d30bbb0596a2967d075cf4b122d162169178acc62dece6bf54
|
|
7
|
+
data.tar.gz: eccd169faf6f16f3e949fe2562e07f46eeaa99d4118bb4c7952526daeeacc6d9337c804640087d1850a6d0ee34efd79aa05e7060889fec2d7b455a62a296b0e5
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,149 @@
|
|
|
1
|
+
## [0.10.0] — 2026-07-23
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Session cleanup** — Auto-prune old or excess sessions via `max_session_age` and `max_sessions` config options. Prunes both `ask_sessions` and `ask_audit_logs` tables.
|
|
6
|
+
- `config.max_session_age = 7.days` — deletes sessions older than the threshold
|
|
7
|
+
- `config.max_sessions = 1000` — keeps at most N sessions, deletes oldest
|
|
8
|
+
- Auto-prunes on every `agent_session` call when configured
|
|
9
|
+
- Also callable manually: `Ask::Rails.cleanup!`
|
|
10
|
+
- Rake task: `rails ask_rails:cleanup`
|
|
11
|
+
|
|
12
|
+
## [0.9.0] — 2026-07-23
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **ReadFile, ReadRoutes, SearchCodebase** — Fixed `ArgumentError: unknown keyword: :data` crash on Ruby 4.0. Tools now return plain Hashes on success instead of using the incompatible `Ask::Result.success` API.
|
|
17
|
+
- **SearchCodebase** — Marked `path` param as optional (was incorrectly required). Added shell escaping for grep patterns.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **23 new execution tests** — ReadFile (3), SearchCodebase (3), ReadRoutes (2), ReadModel (6), plus 6 integration tests. Each tool now has real execution tests beyond just param checks.
|
|
22
|
+
- **160 total tests** — 0 failures, 0 errors.
|
|
23
|
+
|
|
24
|
+
## [0.8.0] — 2026-07-23
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **SchemaGraph tool** — Full application schema introspection in one call. Returns every model, table, column (with types and nullability), association (belongs_to, has_many, has_one, HABTM, through), validation, index, and polymorphic relationship. The agent gets a complete mental model of the app's data layer — no more reading models one at a time.
|
|
29
|
+
- `schema_graph(detail: "all")` — everything
|
|
30
|
+
- `schema_graph(detail: "models")` — just models and columns
|
|
31
|
+
- `schema_graph(detail: "associations")` — just the association graph
|
|
32
|
+
- `schema_graph(detail: "tables")` — just tables, columns, and indexes
|
|
33
|
+
|
|
34
|
+
- **RouteInspector tool** — Parsed Rails route table (not raw routes.rb). Returns every route with HTTP verb, path, controller, action, and name. Supports filtering by controller and path pattern. Replaces the old ReadRoutes which returned raw file content.
|
|
35
|
+
- `route_inspector` — all routes
|
|
36
|
+
- `route_inspector(controller: "users")` — routes for a specific controller
|
|
37
|
+
- `route_inspector(pattern: "admin")` — routes matching a path pattern
|
|
38
|
+
|
|
39
|
+
- **Core tools auto-discovery** — The 9 built-in Rails tools (ReadFile, RunCommand, SearchCodebase, ReadRoutes, QueryDatabase, ReadModel, ReadLog, SchemaGraph, RouteInspector) are now automatically included in every agent session. Previously they were loaded but not registered.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- `discover_tools!` now includes `CORE_RAILS_TOOLS` — all 9 Rails tools are automatically available to the agent.
|
|
44
|
+
|
|
45
|
+
## [0.7.0] — 2026-07-23
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- **Tool execution visualization** — The chat UI now shows live tool execution cards as the agent runs. Each tool call displays as an expandable card with name, args, and real-time elapsed time. Cards show ✓ for success or ✗ for failure with duration.
|
|
50
|
+
- **Activity panel** — New "Activity" sidebar tab shows the audit log in real-time. Browse recent tool calls across all sessions with status, duration, and timestamp.
|
|
51
|
+
- **Per-session audit view** — Clicking a session loads its audit trail. New `GET /ask/sessions/:session_id/audit` endpoint.
|
|
52
|
+
- **Individual session deletion** — Delete sessions one at a time via the ✕ button in the sidebar. New `DELETE /ask/sessions/:id` route.
|
|
53
|
+
- **Sidebar search** — Filter sessions by text in the sidebar search box.
|
|
54
|
+
- **Session previews** — Sidebar shows the first message as a preview instead of just the session ID.
|
|
55
|
+
- **Keyboard shortcuts** — Ctrl+K / Cmd+K focuses the input. Enter sends (Shift+Enter for newline).
|
|
56
|
+
- **Environment badge** — Current Rails.env shown in both the sidebar footer and chat header.
|
|
57
|
+
- **`destroy_session` action** — New controller action for deleting individual sessions.
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- **Chat layout** — Complete visual overhaul: darker theme, improved spacing, tool cards, sidebar tabs (Sessions/Activity), session search, and streaming indicator.
|
|
62
|
+
- **`message` SSE stream** — Now emits `tool_start`, `tool_end`, and `tool_update` events alongside `delta` and `thinking` for real-time tool execution visibility.
|
|
63
|
+
- **Controller actions** — `destroy` renamed to `destroy_all` for clarity.
|
|
64
|
+
|
|
65
|
+
## [0.6.0] — 2026-07-23
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- **Per-environment permissions** — Configure access modes and command allowlists per Rails environment:
|
|
70
|
+
```ruby
|
|
71
|
+
Ask::Rails.configure do |config|
|
|
72
|
+
config.environment :production do |env|
|
|
73
|
+
env.mode = :read_only
|
|
74
|
+
env.allowed_commands = [/^rails routes/]
|
|
75
|
+
env.denied_commands = [/rm/, /dropdb/]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
config.environment :development do |env|
|
|
79
|
+
env.mode = :full_access
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
- **`Ask::Rails::EnvironmentPermissions`** — New config class holding `mode`, `allowed_commands`, and `denied_commands` per environment.
|
|
84
|
+
- **Automatic Permissions wiring** — When an environment `mode` is set, `agent_session` automatically creates an `Ask::Agent::Extensions::Permissions` extension and wires it into the session hooks.
|
|
85
|
+
- **Effective rule resolution** — `Configuration#effective_allowed_commands`, `#effective_denied_commands`, and `#effective_mode` resolve per-environment rules or fall back to global config.
|
|
86
|
+
|
|
87
|
+
## [0.5.0] — 2026-07-22
|
|
88
|
+
|
|
89
|
+
### Added
|
|
90
|
+
|
|
91
|
+
- **Command Allowlist** — `RunCommand` now checks `allowed_commands` and `denied_commands` before executing. Configure with regex patterns:
|
|
92
|
+
- `Ask::Rails.configuration.allowed_commands = [/^rails /, /^git status/]`
|
|
93
|
+
- `Ask::Rails.configuration.denied_commands = [/rm /, /dropdb/]`
|
|
94
|
+
- `denied_commands` takes precedence over `allowed_commands`
|
|
95
|
+
- When `allowed_commands` is nil (default), all commands pass through (except those in `denied_commands`)
|
|
96
|
+
- Blocked commands return `Ask::Result.error` with a descriptive message and are recorded in the audit log
|
|
97
|
+
|
|
98
|
+
- **`allowed_commands` and `denied_commands` configuration options** — Two new arrays of regex patterns on the `Configuration` object.
|
|
99
|
+
|
|
100
|
+
## [0.4.0] — 2026-07-22
|
|
101
|
+
|
|
102
|
+
### Added
|
|
103
|
+
|
|
104
|
+
- **Audit Log** — Every tool call is now recorded in the `ask_audit_logs` table with the intent (sanitized params), outcome (status/timing), and user context. Append-only, never modified. Provides a trustworthy record of what the agent did without storing sensitive data or full results.
|
|
105
|
+
- `Ask::Rails::AuditLog.log` — logs a tool execution event
|
|
106
|
+
- Sensitive params (keys matching `password`, `secret`, `token`, `api_key`, `key`) are automatically redacted as `[REDACTED]`
|
|
107
|
+
- Fires `audit_log.ask_rails` ActiveSupport notification for host app alerting
|
|
108
|
+
- Results are summarized (row count for queries, exit status for commands, etc.) — full data never stored
|
|
109
|
+
- Generator creates the `create_ask_audit_logs` migration
|
|
110
|
+
|
|
111
|
+
- **`current_user` configuration** — `Ask::Rails.configure { |c| c.current_user = -> { Current.user ? { id: Current.user.id, email: Current.user.email } : nil } }` attaches user context to every audit log entry.
|
|
112
|
+
|
|
113
|
+
- **`Ask::Rails::Tool.session_id`** — Thread-local accessor so tool calls are correlated with their agent session in the audit log.
|
|
114
|
+
|
|
115
|
+
### Changed
|
|
116
|
+
|
|
117
|
+
- **Tool base class** — `Ask::Rails::Tool#call` is now instrumented to automatically log every invocation to the audit log. All 7 Rails tools inherit this behavior.
|
|
118
|
+
|
|
119
|
+
## [0.3.0] — 2026-07-21
|
|
120
|
+
|
|
121
|
+
### Added
|
|
122
|
+
|
|
123
|
+
- **Admin chat UI** — Mount `Ask::Rails::Engine` at `/ask` and get a working chat interface with SSE streaming. No generator needed — just mount and go.
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
GET /ask → Chat UI
|
|
127
|
+
POST /ask/sessions → Create new session
|
|
128
|
+
POST /ask/sessions/:id/messages → Send message (SSE streamed)
|
|
129
|
+
GET /ask/sessions/:id/messages → Message history
|
|
130
|
+
GET /ask/sessions/:id/stream → SSE stream for existing session
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- **`Ask::Rails::ChatController`** — Full controller with actions: `index`, `create`, `message` (SSE streaming), `stream`, `history`, `destroy`. Supports real-time streaming via `Enumerator` + `text/event-stream`.
|
|
134
|
+
|
|
135
|
+
- **Chat layout and view** — A dark-themed admin chat interface shipped in the gem (not generated). Includes sidebar with session list, message history, SSE streaming, and keyboard navigation.
|
|
136
|
+
|
|
137
|
+
- **`Ask::Rails::Auth`** — Configurable authentication hook. Set `Ask::Rails::Auth.check = -> { ... }` to protect the chat behind your existing auth system. Runs in controller context — `current_user`, `redirect_to`, etc. are available.
|
|
138
|
+
|
|
139
|
+
- **Engine routes** — Routes defined in `config/routes.rb` with proper `Ask::Rails::Engine.routes.draw` isolation.
|
|
140
|
+
|
|
141
|
+
### Changed
|
|
142
|
+
|
|
143
|
+
- **README** — Rewritten with clear positioning: ask-rails is for internal admin agents. ask-agent is for external-facing agents. Includes comparison table, quick start guide, and tool reference.
|
|
144
|
+
- **Engine** — `isolate_namespace Ask::Rails` for proper route isolation.
|
|
145
|
+
- **Gemspec** — Now includes `app/` and `config/` directories for engine views, controllers, and routes.
|
|
146
|
+
|
|
1
147
|
## [0.2.5] - 2026-06-25
|
|
2
148
|
|
|
3
149
|
### Changed
|
|
@@ -33,3 +179,14 @@
|
|
|
33
179
|
- Rails Tools — `ReadFile`, `RunCommand`, `SearchCodebase`, `ReadRoutes` (Rails.root-aware)
|
|
34
180
|
- Generators — `rails generate ask_rails:install` creates migration, initializer, `app/tools/`
|
|
35
181
|
- Dependencies: rails >= 7.1, ask-tools, ask-tools-shell, ask-agent, ask-auth
|
|
182
|
+
|
|
183
|
+
## [0.11.0] — 2026-07-23
|
|
184
|
+
|
|
185
|
+
### Changed
|
|
186
|
+
|
|
187
|
+
- **`Persistence` adapter now speaks `State::Adapter` contract** — Added `set(key, value)` and `get(key)` methods delegating to `save`/`load`. This makes it compatible with the new `state:` keyword on `Ask::Agent::Session`. Old `save`/`load` interface preserved for backward compatibility.
|
|
188
|
+
- **`agent_session` uses `state:` keyword** — Changed from `persistence:` to `state:`.
|
|
189
|
+
|
|
190
|
+
### Added
|
|
191
|
+
|
|
192
|
+
- **Persistence tests** — New `FakeModel` fixture with 6 real roundtrip tests covering set/get, missing key, delete, list, and old save/load interface.
|
data/README.md
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
# ask-rails
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
An admin AI agent for your Rails app. Mount the engine, get a chat interface at `/ask` that can inspect your code, query your database, read logs, and help you debug — all through an authenticated admin UI.
|
|
4
|
+
|
|
5
|
+
## Who is this for?
|
|
6
|
+
|
|
7
|
+
- **Rails developers** who want an AI co-pilot that understands their app's codebase, schema, routes, and logs
|
|
8
|
+
- **Internal/Admin use only** — the agent has direct access to your database, file system, and shell. Not for external/customer-facing use.
|
|
9
|
+
|
|
10
|
+
For building customer-facing AI agents, use `ask-agent` directly with your own tools and UI.
|
|
11
|
+
|
|
12
|
+
## What it gives you
|
|
13
|
+
|
|
14
|
+
- **7 Rails-aware tools**: `ReadFile`, `QueryDatabase`, `ReadRoutes`, `ReadModel`, `ReadLog`, `RunCommand`, `SearchCodebase`
|
|
15
|
+
- **Admin chat UI**: Mount the engine, get a working chat at `/ask` with SSE streaming
|
|
16
|
+
- **Auth integration**: Protect `/ask` behind your existing Devise/authentication
|
|
17
|
+
- **AR persistence**: Agent sessions survive server restarts
|
|
18
|
+
- **Service discovery**: Auto-detects installed ask-* service gems
|
|
19
|
+
- **Skills**: Built-in guides for Rails debugging, deployment, and database performance
|
|
6
20
|
|
|
7
21
|
## Installation
|
|
8
22
|
|
|
@@ -11,24 +25,94 @@ bundle add ask-rails
|
|
|
11
25
|
rails generate ask_rails:install
|
|
12
26
|
```
|
|
13
27
|
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
Add the engine mount and auth protection to `config/routes.rb`:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# config/routes.rb
|
|
34
|
+
Rails.application.routes.draw do
|
|
35
|
+
# ... your routes ...
|
|
36
|
+
|
|
37
|
+
authenticate :user, ->(u) { u.admin? } do
|
|
38
|
+
mount Ask::Rails::Engine, at: "/ask"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then visit `/ask` in your browser.
|
|
44
|
+
|
|
14
45
|
## Usage
|
|
15
46
|
|
|
47
|
+
### Configuration
|
|
48
|
+
|
|
16
49
|
```ruby
|
|
17
|
-
#
|
|
50
|
+
# config/initializers/ask_rails.rb
|
|
51
|
+
Ask::Rails.configure do |c|
|
|
52
|
+
c.default_model = "claude-sonnet-4"
|
|
53
|
+
c.max_turns = 50
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Programmatic Access
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# From any controller, view, or job
|
|
18
61
|
session = Ask::Rails.agent_session
|
|
19
62
|
session.run("Find all open issues labeled 'bug' in our repo")
|
|
20
63
|
```
|
|
21
64
|
|
|
22
|
-
|
|
23
|
-
context (auth info, quick-start snippets, error guides) in the system prompt automatically.
|
|
65
|
+
### Route Helpers
|
|
24
66
|
|
|
25
|
-
|
|
67
|
+
```ruby
|
|
68
|
+
ask_rails.root_path # => /ask
|
|
69
|
+
ask_rails.sessions_path # => /ask/sessions
|
|
70
|
+
ask_rails.session_messages_path(session_id) # => /ask/sessions/:id/messages
|
|
71
|
+
```
|
|
26
72
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
73
|
+
### Auth
|
|
74
|
+
|
|
75
|
+
By default, the admin chat is unprotected. Add auth in your routes (as shown above) or set a custom check:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# config/initializers/ask_rails.rb
|
|
79
|
+
Ask::Rails::Auth.check = -> {
|
|
80
|
+
redirect_to main_app.login_path unless current_user&.admin?
|
|
81
|
+
}
|
|
30
82
|
```
|
|
31
83
|
|
|
84
|
+
## Tools
|
|
85
|
+
|
|
86
|
+
| Tool | What it does |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `ReadFile` | Read any file (relative to `Rails.root`) |
|
|
89
|
+
| `QueryDatabase` | Run read-only SQL (rejects non-SELECT in production) |
|
|
90
|
+
| `ReadModel` | Inspect AR model schema, associations, validations |
|
|
91
|
+
| `ReadRoutes` | View `config/routes.rb` |
|
|
92
|
+
| `ReadLog` | Read log files with level/search filtering |
|
|
93
|
+
| `RunCommand` | Run shell commands in the app root |
|
|
94
|
+
| `SearchCodebase` | Grep the codebase for patterns |
|
|
95
|
+
|
|
96
|
+
## Engine Routes
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
GET /ask → Chat UI
|
|
100
|
+
POST /ask/sessions → Create new session
|
|
101
|
+
POST /ask/sessions/:id/messages → Send message (SSE streamed response)
|
|
102
|
+
GET /ask/sessions/:id/messages → Get message history
|
|
103
|
+
GET /ask/sessions/:id/stream → SSE stream for existing session
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Compared to ask-agent
|
|
107
|
+
|
|
108
|
+
| `ask-agent` | `ask-rails` |
|
|
109
|
+
|---|---|
|
|
110
|
+
| Build external-facing agents | Build an internal admin co-pilot |
|
|
111
|
+
| Bring your own tools | Ships Rails-specific tools |
|
|
112
|
+
| Bring your own UI | Ships an admin chat UI |
|
|
113
|
+
| Any Ruby app | Rails apps only |
|
|
114
|
+
| General purpose | Development, debugging, ops |
|
|
115
|
+
|
|
32
116
|
## License
|
|
33
117
|
|
|
34
118
|
MIT
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Rails
|
|
7
|
+
class ChatController < ::ActionController::Base
|
|
8
|
+
layout "ask/rails/application"
|
|
9
|
+
|
|
10
|
+
before_action :authenticate!, unless: -> { Ask::Rails::Auth.check.nil? }
|
|
11
|
+
before_action :set_session, only: [:message, :stream, :history, :show, :audit]
|
|
12
|
+
|
|
13
|
+
# GET /ask — main chat interface
|
|
14
|
+
def index
|
|
15
|
+
@sessions = load_sessions
|
|
16
|
+
@active_session = @sessions.first
|
|
17
|
+
@audit_logs = load_recent_audit_logs
|
|
18
|
+
render :index
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# POST /ask/sessions — create a new session
|
|
22
|
+
def create
|
|
23
|
+
session_id = SecureRandom.uuid
|
|
24
|
+
|
|
25
|
+
agent = build_agent_session
|
|
26
|
+
agent.save
|
|
27
|
+
|
|
28
|
+
store_session_metadata(session_id, agent)
|
|
29
|
+
|
|
30
|
+
redirect_to ask_rails.root_path
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# GET /ask/sessions/:id — show a specific session
|
|
34
|
+
def show
|
|
35
|
+
@messages = load_session_messages(@session_id)
|
|
36
|
+
@audit_logs = load_session_audit_logs(@session_id)
|
|
37
|
+
render json: { id: @session_id, messages: @messages, audit_logs: @audit_logs }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# GET /ask/sessions — list all sessions
|
|
41
|
+
def index_sessions
|
|
42
|
+
@sessions = load_sessions
|
|
43
|
+
render json: @sessions
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# POST /ask/sessions/:session_id/messages — send a message, get SSE stream
|
|
47
|
+
def message
|
|
48
|
+
prompt = params[:message].to_s.strip
|
|
49
|
+
return head :bad_request if prompt.empty?
|
|
50
|
+
|
|
51
|
+
response.headers["Content-Type"] = "text/event-stream"
|
|
52
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
53
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
54
|
+
response.headers["Last-Modified"] = Time.now.httpdate
|
|
55
|
+
|
|
56
|
+
self.response_body = Enumerator.new do |yielder|
|
|
57
|
+
agent = resume_or_create_session(@session_id)
|
|
58
|
+
|
|
59
|
+
# Subscribe to tool execution events
|
|
60
|
+
tool_sub = subscribe_tool_events(agent, yielder)
|
|
61
|
+
|
|
62
|
+
yielder << "data: #{JSON.generate(type: 'start', session_id: @session_id)}\n\n"
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
agent.run(prompt) do |chunk|
|
|
66
|
+
if chunk.content&.length&.> 0
|
|
67
|
+
yielder << "data: #{JSON.generate(type: 'delta', content: chunk.content)}\n\n"
|
|
68
|
+
end
|
|
69
|
+
if chunk.thinking&.length&.> 0
|
|
70
|
+
yielder << "data: #{JSON.generate(type: 'thinking', content: chunk.thinking)}\n\n"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
agent.save
|
|
75
|
+
@session = agent
|
|
76
|
+
|
|
77
|
+
yielder << "data: #{JSON.generate(type: 'done', session_id: @session_id)}\n\n"
|
|
78
|
+
rescue Ask::Agent::MaxTurnsExceeded => e
|
|
79
|
+
yielder << "data: #{JSON.generate(type: 'error', message: "Agent hit the turn limit (#{e.message})")}\n\n"
|
|
80
|
+
rescue Ask::Auth::MissingCredential => e
|
|
81
|
+
yielder << "data: #{JSON.generate(type: 'error', message: "Missing API key: #{e.message}. Check your provider configuration.")}\n\n"
|
|
82
|
+
rescue Ask::Auth::InvalidCredential => e
|
|
83
|
+
yielder << "data: #{JSON.generate(type: 'error', message: "Invalid API key: #{e.message}. Update your credentials.")}\n\n"
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
yielder << "data: #{JSON.generate(type: 'error', message: "Agent error: #{e.message}")}\n\n"
|
|
86
|
+
end
|
|
87
|
+
ensure
|
|
88
|
+
agent&.remove_event_subscriber(tool_sub) if tool_sub
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# GET /ask/sessions/:session_id/stream — SSE stream for an existing session
|
|
93
|
+
def stream
|
|
94
|
+
response.headers["Content-Type"] = "text/event-stream"
|
|
95
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
96
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
97
|
+
|
|
98
|
+
self.response_body = Enumerator.new do |yielder|
|
|
99
|
+
messages = load_session_messages(@session_id)
|
|
100
|
+
audit_logs = load_session_audit_logs(@session_id)
|
|
101
|
+
yielder << "data: #{JSON.generate(type: 'history', messages: messages, audit_logs: audit_logs)}\n\n"
|
|
102
|
+
|
|
103
|
+
sleep 30
|
|
104
|
+
yielder << "data: #{JSON.generate(type: 'keepalive')}\n\n"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# GET /ask/sessions/:session_id/messages — get message history as JSON
|
|
109
|
+
def history
|
|
110
|
+
messages = load_session_messages(@session_id)
|
|
111
|
+
render json: messages
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# GET /ask/sessions/:session_id/audit — get audit logs for a session
|
|
115
|
+
def audit
|
|
116
|
+
logs = load_session_audit_logs(@session_id)
|
|
117
|
+
render json: logs
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# DELETE /ask/sessions — destroy all sessions
|
|
121
|
+
def destroy_all
|
|
122
|
+
persistence = Ask::Rails.configuration.persistence_adapter
|
|
123
|
+
if persistence
|
|
124
|
+
persistence.list.each { |id| persistence.delete(id) }
|
|
125
|
+
end
|
|
126
|
+
redirect_to ask_rails.root_path
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# DELETE /ask/sessions/:session_id — destroy a specific session
|
|
130
|
+
def destroy_session
|
|
131
|
+
persistence = Ask::Rails.configuration.persistence_adapter
|
|
132
|
+
persistence&.delete(params[:session_id])
|
|
133
|
+
redirect_to ask_rails.root_path
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def authenticate!
|
|
139
|
+
instance_eval(&Ask::Rails::Auth.check) if Ask::Rails::Auth.check
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def set_session
|
|
143
|
+
@session_id = params[:session_id] || params[:id]
|
|
144
|
+
head :not_found unless @session_id
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def build_agent_session(**extra)
|
|
148
|
+
Ask::Rails.agent_session(**extra)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def subscribe_tool_events(agent, yielder)
|
|
152
|
+
return nil unless agent.respond_to?(:on_event)
|
|
153
|
+
|
|
154
|
+
agent.on_event do |event|
|
|
155
|
+
case event
|
|
156
|
+
when Ask::Agent::Events::ToolExecutionStart
|
|
157
|
+
yielder << "data: #{JSON.generate(
|
|
158
|
+
type: 'tool_start',
|
|
159
|
+
name: event.name,
|
|
160
|
+
id: event.id,
|
|
161
|
+
args: safe_tool_args(event.arguments)
|
|
162
|
+
)}\n\n"
|
|
163
|
+
when Ask::Agent::Events::ToolExecutionEnd
|
|
164
|
+
yielder << "data: #{JSON.generate(
|
|
165
|
+
type: 'tool_end',
|
|
166
|
+
name: event.name,
|
|
167
|
+
id: event.id,
|
|
168
|
+
duration_ms: event.duration_ms,
|
|
169
|
+
is_error: event.is_error
|
|
170
|
+
)}\n\n"
|
|
171
|
+
when Ask::Agent::Events::ToolExecutionUpdate
|
|
172
|
+
yielder << "data: #{JSON.generate(
|
|
173
|
+
type: 'tool_update',
|
|
174
|
+
id: event.id,
|
|
175
|
+
content: event.partial_result.to_s.truncate(200)
|
|
176
|
+
)}\n\n"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def safe_tool_args(args)
|
|
182
|
+
return {} unless args.is_a?(Hash)
|
|
183
|
+
|
|
184
|
+
safe = args.dup
|
|
185
|
+
%w[password secret token api_key key auth_token access_token sql command].each do |sensitive|
|
|
186
|
+
safe[sensitive] = "[REDACTED]" if safe.key?(sensitive)
|
|
187
|
+
end
|
|
188
|
+
safe
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def resume_or_create_session(session_id)
|
|
192
|
+
persistence = Ask::Rails.configuration.persistence_adapter
|
|
193
|
+
|
|
194
|
+
if persistence
|
|
195
|
+
data = persistence.load(session_id)
|
|
196
|
+
if data
|
|
197
|
+
session = Ask::Agent::Session.load(session_id, adapter: persistence)
|
|
198
|
+
return session if session
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
Ask::Rails.agent_session(persistence: persistence).tap do |s|
|
|
203
|
+
s.instance_variable_set(:@id, session_id)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def load_sessions
|
|
208
|
+
persistence = Ask::Rails.configuration.persistence_adapter
|
|
209
|
+
return [] unless persistence
|
|
210
|
+
|
|
211
|
+
persistence.list.map do |id|
|
|
212
|
+
data = persistence.load(id) || {}
|
|
213
|
+
messages = data[:messages] || []
|
|
214
|
+
{ id: id, created_at: data.dig(:metadata, :created_at), message_count: messages.length, preview: messages.first&.dig(:content).to_s.truncate(60) }
|
|
215
|
+
end.sort_by { |s| s[:created_at] || "" }.reverse
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def load_session_messages(session_id)
|
|
219
|
+
persistence = Ask::Rails.configuration.persistence_adapter
|
|
220
|
+
return [] unless persistence
|
|
221
|
+
|
|
222
|
+
data = persistence.load(session_id)
|
|
223
|
+
return [] unless data
|
|
224
|
+
|
|
225
|
+
(data[:messages] || []).map { |m|
|
|
226
|
+
{ role: m[:role], content: m[:content].to_s }
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def load_session_audit_logs(session_id)
|
|
231
|
+
return [] unless defined?(ActiveRecord::Base)
|
|
232
|
+
|
|
233
|
+
ActiveRecord::Base.connection.execute(
|
|
234
|
+
"SELECT tool_name, status, duration_ms, environment, recorded_at, " \
|
|
235
|
+
"params, result_summary, error_message, user_context " \
|
|
236
|
+
"FROM ask_audit_logs WHERE session_id = #{ActiveRecord::Base.connection.quote(session_id.to_s)} " \
|
|
237
|
+
"ORDER BY recorded_at ASC"
|
|
238
|
+
).map do |row|
|
|
239
|
+
{
|
|
240
|
+
tool_name: row["tool_name"],
|
|
241
|
+
status: row["status"],
|
|
242
|
+
duration_ms: row["duration_ms"],
|
|
243
|
+
environment: row["environment"],
|
|
244
|
+
recorded_at: row["recorded_at"],
|
|
245
|
+
params: parse_json_field(row["params"]),
|
|
246
|
+
result_summary: parse_json_field(row["result_summary"]),
|
|
247
|
+
error_message: row["error_message"],
|
|
248
|
+
user_context: parse_json_field(row["user_context"])
|
|
249
|
+
}
|
|
250
|
+
end
|
|
251
|
+
rescue StandardError
|
|
252
|
+
[]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def load_recent_audit_logs
|
|
256
|
+
return [] unless defined?(ActiveRecord::Base)
|
|
257
|
+
|
|
258
|
+
ActiveRecord::Base.connection.execute(
|
|
259
|
+
"SELECT session_id, tool_name, status, duration_ms, recorded_at " \
|
|
260
|
+
"FROM ask_audit_logs ORDER BY recorded_at DESC LIMIT 100"
|
|
261
|
+
).map do |row|
|
|
262
|
+
{
|
|
263
|
+
session_id: row["session_id"].to_s[0..7],
|
|
264
|
+
tool_name: row["tool_name"],
|
|
265
|
+
status: row["status"],
|
|
266
|
+
duration_ms: row["duration_ms"],
|
|
267
|
+
recorded_at: row["recorded_at"]
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
rescue StandardError
|
|
271
|
+
[]
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def parse_json_field(value)
|
|
275
|
+
return nil unless value
|
|
276
|
+
return value if value.is_a?(Hash) || value.is_a?(Array)
|
|
277
|
+
JSON.parse(value)
|
|
278
|
+
rescue JSON::ParserError
|
|
279
|
+
value
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def store_session_metadata(session_id, agent)
|
|
283
|
+
persistence = Ask::Rails.configuration.persistence_adapter
|
|
284
|
+
return unless persistence
|
|
285
|
+
|
|
286
|
+
persistence.save(session_id, {
|
|
287
|
+
id: session_id,
|
|
288
|
+
messages: agent.messages.map { |m|
|
|
289
|
+
{ role: m.role, content: m.content.to_s }
|
|
290
|
+
},
|
|
291
|
+
metadata: {
|
|
292
|
+
model: ask_rails_model,
|
|
293
|
+
created_at: Time.now.iso8601,
|
|
294
|
+
updated_at: Time.now.iso8601
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def ask_rails_model
|
|
300
|
+
Ask::Rails.configuration.default_model
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def tool_events_supported?
|
|
304
|
+
defined?(Ask::Agent::Events::ToolExecutionStart)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
# ActiveRecord model for persisting agent sessions.
|
|
6
|
+
#
|
|
7
|
+
# Created by the +ask_rails:install+ generator. The +ask_sessions+ table
|
|
8
|
+
# stores session state in a JSONB +data+ column, keyed by a unique
|
|
9
|
+
# +session_id+ string.
|
|
10
|
+
#
|
|
11
|
+
# Used automatically when +persistence_adapter+ is configured with the
|
|
12
|
+
# default model class:
|
|
13
|
+
#
|
|
14
|
+
# Ask::Rails.configure do |config|
|
|
15
|
+
# config.persistence_adapter = Ask::Rails::Persistence.new
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Without +model_class:+, +Persistence+ defaults to +Ask::Rails::Session+.
|
|
19
|
+
class Session < ActiveRecord::Base
|
|
20
|
+
self.table_name = "ask_sessions"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<!-- Chat interface rendered by the layout -->
|
|
2
|
+
<!-- The layout handles the full UI. This view is minimal -->
|
|
3
|
+
<!-- since the layout and JavaScript drive the interface. -->
|
|
4
|
+
|
|
5
|
+
<%= tag.div id: "ask-rails-data",
|
|
6
|
+
data: { sessions: @sessions.to_json, active_session_id: @active_session&.dig(:id) } %>
|