ask-rails-harness 0.1.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 +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +122 -0
- data/app/controllers/ask/rails/harness/chat_controller.rb +310 -0
- data/app/models/ask/rails/harness/session.rb +25 -0
- data/app/views/ask/rails/harness/chat/index.html.erb +6 -0
- data/app/views/layouts/ask/rails/harness/application.html.erb +521 -0
- data/config/routes.rb +17 -0
- data/lib/ask/rails/harness/audit_log.rb +183 -0
- data/lib/ask/rails/harness/auth.rb +24 -0
- data/lib/ask/rails/harness/configuration.rb +73 -0
- data/lib/ask/rails/harness/engine.rb +13 -0
- data/lib/ask/rails/harness/environment_permissions.rb +41 -0
- data/lib/ask/rails/harness/persistence.rb +54 -0
- data/lib/ask/rails/harness/railtie.rb +34 -0
- data/lib/ask/rails/harness/service_discovery.rb +50 -0
- data/lib/ask/rails/harness/tool.rb +42 -0
- data/lib/ask/rails/harness/tools/query_database.rb +74 -0
- data/lib/ask/rails/harness/tools/read_file.rb +23 -0
- data/lib/ask/rails/harness/tools/read_log.rb +120 -0
- data/lib/ask/rails/harness/tools/read_model.rb +75 -0
- data/lib/ask/rails/harness/tools/read_routes.rb +20 -0
- data/lib/ask/rails/harness/tools/route_inspector.rb +71 -0
- data/lib/ask/rails/harness/tools/run_command.rb +60 -0
- data/lib/ask/rails/harness/tools/schema_graph.rb +176 -0
- data/lib/ask/rails/harness/tools/search_codebase.rb +23 -0
- data/lib/ask/rails/harness/version.rb +9 -0
- data/lib/ask/rails/harness.rb +189 -0
- data/lib/ask/skills/rails.db_debug/SKILL.md +118 -0
- data/lib/ask/skills/rails.deploy_pipeline/SKILL.md +125 -0
- data/lib/ask/skills/rails.route_trouble/SKILL.md +127 -0
- data/lib/ask-rails-harness.rb +3 -0
- data/lib/generators/ask/rails/harness/install/install_generator.rb +36 -0
- data/lib/generators/ask/rails/harness/install/templates/audit_log_migration.rb +22 -0
- data/lib/generators/ask/rails/harness/install/templates/initializer.rb +5 -0
- data/lib/generators/ask/rails/harness/install/templates/migration.rb +12 -0
- metadata +208 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1c1a8388500ab4366c876be18f2b8fb2fd2c489fcd6c04da90c2a1ee9eea8b59
|
|
4
|
+
data.tar.gz: 6012eff1acc1cdecd8b606a91027f517a69ddb77d252c0aae8846e23b5e7f15e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7492561a0888730defddcff380d3800afefab364f41bf4df2e842a1894cdc4229c08e7d9c249df0b2d8ece1d0e9e345369840434a1af10d8802eb2f9f2fac4c4
|
|
7
|
+
data.tar.gz: 490b772093ac41af1ca679dfa55af7500ede1c634d01e82a3f7393ba23381fdf30507bf95929d8405e824ab7daf0d05f17052164a41724e8547bf7c4f3848abb
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
## [0.1.0] — 2026-07-25
|
|
2
|
+
|
|
3
|
+
### Changed
|
|
4
|
+
|
|
5
|
+
- **Gem renamed from `ask-rails` to `ask-rails-harness`** — The gem has been renamed to better reflect its purpose as an admin AI copilot harness for Rails apps. The old `ask-rails` gem name has been yanked from rubygems.org to make room for a new `ask-rails` gem with a different purpose.
|
|
6
|
+
- **Module namespace moved from `Ask::Rails` to `Ask::Rails::Harness`** — All code now lives under `Ask::Rails::Harness::*` to coexist with the planned `ask-rails` gem.
|
|
7
|
+
- **Engine namespace changed** — `isolate_namespace` updated from `Ask::Rails` to `Ask::Rails::Harness`. Route helpers change from `ask_rails.*` to `ask_rails_harness.*`.
|
|
8
|
+
- **Generator renamed** — Use `rails generate ask_rails_harness:install` instead of `ask_rails:install`.
|
|
9
|
+
- **Initializer renamed** — Generated file is now `config/initializers/ask_rails_harness.rb`.
|
|
10
|
+
- **Rake task renamed** — Use `rails ask_rails_harness:cleanup` instead of `ask_rails:cleanup`.
|
|
11
|
+
- **Notification name changed** — `audit_log.ask_rails` is now `audit_log.ask_rails_harness`.
|
|
12
|
+
- **Log prefix changed** — `[ask-rails]` is now `[ask-rails-harness]`.
|
|
13
|
+
|
|
14
|
+
### Migration from ask-rails
|
|
15
|
+
|
|
16
|
+
Existing applications using `ask-rails` should:
|
|
17
|
+
1. Replace `gem "ask-rails"` with `gem "ask-rails-harness"` in your Gemfile
|
|
18
|
+
2. Update `Ask::Rails.configure` → `Ask::Rails::Harness.configure`
|
|
19
|
+
3. Update `mount Ask::Rails::Engine` → `mount Ask::Rails::Harness::Engine`
|
|
20
|
+
4. Update `ask_rails.*` route helpers → `ask_rails_harness.*`
|
|
21
|
+
5. Update `Ask::Rails::Auth` → `Ask::Rails::Harness::Auth`
|
|
22
|
+
6. Rename initializer from `ask_rails.rb` to `ask_rails_harness.rb`
|
|
23
|
+
|
|
24
|
+
All previous versions (0.1.0–0.11.1) were released under the `ask-rails` gem name. See the [ask-rails CHANGELOG](https://github.com/ask-rb/ask-rails) for history of those releases.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# ask-rails-harness
|
|
2
|
+
|
|
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
|
+
> **Previously developed as `ask-rails` (v0.1.0–0.11.1).** Renamed to `ask-rails-harness` for clarity — this gem is the *harness* that wraps an AI agent with Rails-aware tools, environment, state, and feedback for internal/admin use.
|
|
6
|
+
|
|
7
|
+
## Who is this for?
|
|
8
|
+
|
|
9
|
+
- **Rails developers** who want an AI co-pilot that understands their app's codebase, schema, routes, and logs
|
|
10
|
+
- **Internal/Admin use only** — the agent has direct access to your database, file system, and shell. Not for external/customer-facing use.
|
|
11
|
+
|
|
12
|
+
For building customer-facing AI agents, use `ask-agent` directly with your own tools and UI.
|
|
13
|
+
|
|
14
|
+
## What it gives you
|
|
15
|
+
|
|
16
|
+
- **9 Rails-aware tools**: `ReadFile`, `QueryDatabase`, `ReadRoutes`, `ReadModel`, `ReadLog`, `RunCommand`, `SearchCodebase`, `SchemaGraph`, `RouteInspector`
|
|
17
|
+
- **Admin chat UI**: Mount the engine, get a working chat at `/ask` with SSE streaming
|
|
18
|
+
- **Auth integration**: Protect `/ask` behind your existing Devise/authentication
|
|
19
|
+
- **AR persistence**: Agent sessions survive server restarts
|
|
20
|
+
- **Service discovery**: Auto-detects installed ask-\* service gems
|
|
21
|
+
- **Skills**: Built-in guides for Rails debugging, deployment, and database performance
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bundle add ask-rails-harness
|
|
27
|
+
rails generate ask_rails_harness:install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
Add the engine mount and auth protection to `config/routes.rb`:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
# config/routes.rb
|
|
36
|
+
Rails.application.routes.draw do
|
|
37
|
+
# ... your routes ...
|
|
38
|
+
|
|
39
|
+
authenticate :user, ->(u) { u.admin? } do
|
|
40
|
+
mount Ask::Rails::Harness::Engine, at: "/ask"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then visit `/ask` in your browser.
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
### Configuration
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
# config/initializers/ask_rails_harness.rb
|
|
53
|
+
Ask::Rails::Harness.configure do |c|
|
|
54
|
+
c.default_model = "claude-sonnet-4"
|
|
55
|
+
c.max_turns = 50
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Programmatic Access
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# From any controller, view, or job
|
|
63
|
+
session = Ask::Rails::Harness.agent_session
|
|
64
|
+
session.run("Find all open issues labeled 'bug' in our repo")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Route Helpers
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
ask_rails_harness.root_path # => /ask
|
|
71
|
+
ask_rails_harness.sessions_path # => /ask/sessions
|
|
72
|
+
ask_rails_harness.session_messages_path(session_id) # => /ask/sessions/:id/messages
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Auth
|
|
76
|
+
|
|
77
|
+
By default, the admin chat is unprotected. Add auth in your routes (as shown above) or set a custom check:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# config/initializers/ask_rails_harness.rb
|
|
81
|
+
Ask::Rails::Harness::Auth.check = -> {
|
|
82
|
+
redirect_to main_app.login_path unless current_user&.admin?
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Tools
|
|
87
|
+
|
|
88
|
+
| Tool | What it does |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `ReadFile` | Read any file (relative to `Rails.root`) |
|
|
91
|
+
| `QueryDatabase` | Run read-only SQL (rejects non-SELECT in production) |
|
|
92
|
+
| `ReadModel` | Inspect AR model schema, associations, validations |
|
|
93
|
+
| `ReadRoutes` | View `config/routes.rb` |
|
|
94
|
+
| `ReadLog` | Read log files with level/search filtering |
|
|
95
|
+
| `RunCommand` | Run shell commands in the app root |
|
|
96
|
+
| `SearchCodebase` | Grep the codebase for patterns |
|
|
97
|
+
| `SchemaGraph` | Full schema introspection — all models, tables, columns, associations |
|
|
98
|
+
| `RouteInspector` | Parsed route table with filters |
|
|
99
|
+
|
|
100
|
+
## Engine Routes
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
GET /ask → Chat UI
|
|
104
|
+
POST /ask/sessions → Create new session
|
|
105
|
+
POST /ask/sessions/:id/messages → Send message (SSE streamed response)
|
|
106
|
+
GET /ask/sessions/:id/messages → Get message history
|
|
107
|
+
GET /ask/sessions/:id/stream → SSE stream for existing session
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Compared to ask-agent
|
|
111
|
+
|
|
112
|
+
| `ask-agent` | `ask-rails-harness` |
|
|
113
|
+
|---|---|
|
|
114
|
+
| Build external-facing agents | Build an internal admin co-pilot |
|
|
115
|
+
| Bring your own tools | Ships Rails-specific tools |
|
|
116
|
+
| Bring your own UI | Ships an admin chat UI |
|
|
117
|
+
| Any Ruby app | Rails apps only |
|
|
118
|
+
| General purpose | Development, debugging, ops |
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Rails
|
|
7
|
+
module Harness
|
|
8
|
+
class ChatController < ::ActionController::Base
|
|
9
|
+
layout "ask/rails/harness/application"
|
|
10
|
+
|
|
11
|
+
before_action :authenticate!, unless: -> { Ask::Rails::Harness::Auth.check.nil? }
|
|
12
|
+
before_action :set_session, only: [:message, :stream, :history, :show, :audit]
|
|
13
|
+
|
|
14
|
+
# GET /ask — main chat interface
|
|
15
|
+
def index
|
|
16
|
+
@sessions = load_sessions
|
|
17
|
+
@active_session = @sessions.first
|
|
18
|
+
@audit_logs = load_recent_audit_logs
|
|
19
|
+
render :index
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# POST /ask/sessions — create a new session
|
|
23
|
+
def create
|
|
24
|
+
session_id = SecureRandom.uuid
|
|
25
|
+
|
|
26
|
+
agent = build_agent_session
|
|
27
|
+
agent.save
|
|
28
|
+
|
|
29
|
+
store_session_metadata(session_id, agent)
|
|
30
|
+
|
|
31
|
+
redirect_to ask_rails_harness.root_path
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# GET /ask/sessions/:id — show a specific session
|
|
35
|
+
def show
|
|
36
|
+
@messages = load_session_messages(@session_id)
|
|
37
|
+
@audit_logs = load_session_audit_logs(@session_id)
|
|
38
|
+
render json: { id: @session_id, messages: @messages, audit_logs: @audit_logs }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# GET /ask/sessions — list all sessions
|
|
42
|
+
def index_sessions
|
|
43
|
+
@sessions = load_sessions
|
|
44
|
+
render json: @sessions
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# POST /ask/sessions/:session_id/messages — send a message, get SSE stream
|
|
48
|
+
def message
|
|
49
|
+
prompt = params[:message].to_s.strip
|
|
50
|
+
return head :bad_request if prompt.empty?
|
|
51
|
+
|
|
52
|
+
response.headers["Content-Type"] = "text/event-stream"
|
|
53
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
54
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
55
|
+
response.headers["Last-Modified"] = Time.now.httpdate
|
|
56
|
+
|
|
57
|
+
self.response_body = Enumerator.new do |yielder|
|
|
58
|
+
agent = resume_or_create_session(@session_id)
|
|
59
|
+
|
|
60
|
+
# Subscribe to tool execution events
|
|
61
|
+
tool_sub = subscribe_tool_events(agent, yielder)
|
|
62
|
+
|
|
63
|
+
yielder << "data: #{JSON.generate(type: 'start', session_id: @session_id)}\n\n"
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
agent.run(prompt) do |chunk|
|
|
67
|
+
if chunk.content&.length&.> 0
|
|
68
|
+
yielder << "data: #{JSON.generate(type: 'delta', content: chunk.content)}\n\n"
|
|
69
|
+
end
|
|
70
|
+
if chunk.thinking&.length&.> 0
|
|
71
|
+
yielder << "data: #{JSON.generate(type: 'thinking', content: chunk.thinking)}\n\n"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
agent.save
|
|
76
|
+
@session = agent
|
|
77
|
+
|
|
78
|
+
yielder << "data: #{JSON.generate(type: 'done', session_id: @session_id)}\n\n"
|
|
79
|
+
rescue Ask::Agent::MaxTurnsExceeded => e
|
|
80
|
+
yielder << "data: #{JSON.generate(type: 'error', message: "Agent hit the turn limit (#{e.message})")}\n\n"
|
|
81
|
+
rescue Ask::Auth::MissingCredential => e
|
|
82
|
+
yielder << "data: #{JSON.generate(type: 'error', message: "Missing API key: #{e.message}. Check your provider configuration.")}\n\n"
|
|
83
|
+
rescue Ask::Auth::InvalidCredential => e
|
|
84
|
+
yielder << "data: #{JSON.generate(type: 'error', message: "Invalid API key: #{e.message}. Update your credentials.")}\n\n"
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
yielder << "data: #{JSON.generate(type: 'error', message: "Agent error: #{e.message}")}\n\n"
|
|
87
|
+
end
|
|
88
|
+
ensure
|
|
89
|
+
agent&.remove_event_subscriber(tool_sub) if tool_sub
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# GET /ask/sessions/:session_id/stream — SSE stream for an existing session
|
|
94
|
+
def stream
|
|
95
|
+
response.headers["Content-Type"] = "text/event-stream"
|
|
96
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
97
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
98
|
+
|
|
99
|
+
self.response_body = Enumerator.new do |yielder|
|
|
100
|
+
messages = load_session_messages(@session_id)
|
|
101
|
+
audit_logs = load_session_audit_logs(@session_id)
|
|
102
|
+
yielder << "data: #{JSON.generate(type: 'history', messages: messages, audit_logs: audit_logs)}\n\n"
|
|
103
|
+
|
|
104
|
+
sleep 30
|
|
105
|
+
yielder << "data: #{JSON.generate(type: 'keepalive')}\n\n"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# GET /ask/sessions/:session_id/messages — get message history as JSON
|
|
110
|
+
def history
|
|
111
|
+
messages = load_session_messages(@session_id)
|
|
112
|
+
render json: messages
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# GET /ask/sessions/:session_id/audit — get audit logs for a session
|
|
116
|
+
def audit
|
|
117
|
+
logs = load_session_audit_logs(@session_id)
|
|
118
|
+
render json: logs
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# DELETE /ask/sessions — destroy all sessions
|
|
122
|
+
def destroy_all
|
|
123
|
+
persistence = Ask::Rails::Harness.configuration.persistence_adapter
|
|
124
|
+
if persistence
|
|
125
|
+
persistence.list.each { |id| persistence.delete(id) }
|
|
126
|
+
end
|
|
127
|
+
redirect_to ask_rails_harness.root_path
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# DELETE /ask/sessions/:session_id — destroy a specific session
|
|
131
|
+
def destroy_session
|
|
132
|
+
persistence = Ask::Rails::Harness.configuration.persistence_adapter
|
|
133
|
+
persistence&.delete(params[:session_id])
|
|
134
|
+
redirect_to ask_rails_harness.root_path
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def authenticate!
|
|
140
|
+
instance_eval(&Ask::Rails::Harness::Auth.check) if Ask::Rails::Harness::Auth.check
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def set_session
|
|
144
|
+
@session_id = params[:session_id] || params[:id]
|
|
145
|
+
head :not_found unless @session_id
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_agent_session(**extra)
|
|
149
|
+
Ask::Rails::Harness.agent_session(**extra)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def subscribe_tool_events(agent, yielder)
|
|
153
|
+
return nil unless agent.respond_to?(:on_event)
|
|
154
|
+
|
|
155
|
+
agent.on_event do |event|
|
|
156
|
+
case event
|
|
157
|
+
when Ask::Agent::Events::ToolExecutionStart
|
|
158
|
+
yielder << "data: #{JSON.generate(
|
|
159
|
+
type: 'tool_start',
|
|
160
|
+
name: event.name,
|
|
161
|
+
id: event.id,
|
|
162
|
+
args: safe_tool_args(event.arguments)
|
|
163
|
+
)}\n\n"
|
|
164
|
+
when Ask::Agent::Events::ToolExecutionEnd
|
|
165
|
+
yielder << "data: #{JSON.generate(
|
|
166
|
+
type: 'tool_end',
|
|
167
|
+
name: event.name,
|
|
168
|
+
id: event.id,
|
|
169
|
+
duration_ms: event.duration_ms,
|
|
170
|
+
is_error: event.is_error
|
|
171
|
+
)}\n\n"
|
|
172
|
+
when Ask::Agent::Events::ToolExecutionUpdate
|
|
173
|
+
yielder << "data: #{JSON.generate(
|
|
174
|
+
type: 'tool_update',
|
|
175
|
+
id: event.id,
|
|
176
|
+
content: event.partial_result.to_s.truncate(200)
|
|
177
|
+
)}\n\n"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def safe_tool_args(args)
|
|
183
|
+
return {} unless args.is_a?(Hash)
|
|
184
|
+
|
|
185
|
+
safe = args.dup
|
|
186
|
+
%w[password secret token api_key key auth_token access_token sql command].each do |sensitive|
|
|
187
|
+
safe[sensitive] = "[REDACTED]" if safe.key?(sensitive)
|
|
188
|
+
end
|
|
189
|
+
safe
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def resume_or_create_session(session_id)
|
|
193
|
+
persistence = Ask::Rails::Harness.configuration.persistence_adapter
|
|
194
|
+
|
|
195
|
+
if persistence
|
|
196
|
+
data = persistence.load(session_id)
|
|
197
|
+
if data
|
|
198
|
+
session = Ask::Agent::Session.load(session_id, adapter: persistence)
|
|
199
|
+
return session if session
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
Ask::Rails::Harness.agent_session(persistence: persistence).tap do |s|
|
|
204
|
+
s.instance_variable_set(:@id, session_id)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def load_sessions
|
|
209
|
+
persistence = Ask::Rails::Harness.configuration.persistence_adapter
|
|
210
|
+
return [] unless persistence
|
|
211
|
+
|
|
212
|
+
persistence.list.map do |id|
|
|
213
|
+
data = persistence.load(id) || {}
|
|
214
|
+
messages = data[:messages] || []
|
|
215
|
+
{ id: id, created_at: data.dig(:metadata, :created_at), message_count: messages.length, preview: messages.first&.dig(:content).to_s.truncate(60) }
|
|
216
|
+
end.sort_by { |s| s[:created_at] || "" }.reverse
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def load_session_messages(session_id)
|
|
220
|
+
persistence = Ask::Rails::Harness.configuration.persistence_adapter
|
|
221
|
+
return [] unless persistence
|
|
222
|
+
|
|
223
|
+
data = persistence.load(session_id)
|
|
224
|
+
return [] unless data
|
|
225
|
+
|
|
226
|
+
(data[:messages] || []).map { |m|
|
|
227
|
+
{ role: m[:role], content: m[:content].to_s }
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def load_session_audit_logs(session_id)
|
|
232
|
+
return [] unless defined?(ActiveRecord::Base)
|
|
233
|
+
|
|
234
|
+
ActiveRecord::Base.connection.execute(
|
|
235
|
+
"SELECT tool_name, status, duration_ms, environment, recorded_at, " \
|
|
236
|
+
"params, result_summary, error_message, user_context " \
|
|
237
|
+
"FROM ask_audit_logs WHERE session_id = #{ActiveRecord::Base.connection.quote(session_id.to_s)} " \
|
|
238
|
+
"ORDER BY recorded_at ASC"
|
|
239
|
+
).map do |row|
|
|
240
|
+
{
|
|
241
|
+
tool_name: row["tool_name"],
|
|
242
|
+
status: row["status"],
|
|
243
|
+
duration_ms: row["duration_ms"],
|
|
244
|
+
environment: row["environment"],
|
|
245
|
+
recorded_at: row["recorded_at"],
|
|
246
|
+
params: parse_json_field(row["params"]),
|
|
247
|
+
result_summary: parse_json_field(row["result_summary"]),
|
|
248
|
+
error_message: row["error_message"],
|
|
249
|
+
user_context: parse_json_field(row["user_context"])
|
|
250
|
+
}
|
|
251
|
+
end
|
|
252
|
+
rescue StandardError
|
|
253
|
+
[]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def load_recent_audit_logs
|
|
257
|
+
return [] unless defined?(ActiveRecord::Base)
|
|
258
|
+
|
|
259
|
+
ActiveRecord::Base.connection.execute(
|
|
260
|
+
"SELECT session_id, tool_name, status, duration_ms, recorded_at " \
|
|
261
|
+
"FROM ask_audit_logs ORDER BY recorded_at DESC LIMIT 100"
|
|
262
|
+
).map do |row|
|
|
263
|
+
{
|
|
264
|
+
session_id: row["session_id"].to_s[0..7],
|
|
265
|
+
tool_name: row["tool_name"],
|
|
266
|
+
status: row["status"],
|
|
267
|
+
duration_ms: row["duration_ms"],
|
|
268
|
+
recorded_at: row["recorded_at"]
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
rescue StandardError
|
|
272
|
+
[]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def parse_json_field(value)
|
|
276
|
+
return nil unless value
|
|
277
|
+
return value if value.is_a?(Hash) || value.is_a?(Array)
|
|
278
|
+
JSON.parse(value)
|
|
279
|
+
rescue JSON::ParserError
|
|
280
|
+
value
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def store_session_metadata(session_id, agent)
|
|
284
|
+
persistence = Ask::Rails::Harness.configuration.persistence_adapter
|
|
285
|
+
return unless persistence
|
|
286
|
+
|
|
287
|
+
persistence.save(session_id, {
|
|
288
|
+
id: session_id,
|
|
289
|
+
messages: agent.messages.map { |m|
|
|
290
|
+
{ role: m.role, content: m.content.to_s }
|
|
291
|
+
},
|
|
292
|
+
metadata: {
|
|
293
|
+
model: ask_rails_harness_model,
|
|
294
|
+
created_at: Time.now.iso8601,
|
|
295
|
+
updated_at: Time.now.iso8601
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def ask_rails_harness_model
|
|
301
|
+
Ask::Rails::Harness.configuration.default_model
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def tool_events_supported?
|
|
305
|
+
defined?(Ask::Agent::Events::ToolExecutionStart)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
# ActiveRecord model for persisting agent sessions.
|
|
7
|
+
#
|
|
8
|
+
# Created by the +ask_rails_harness:install+ generator. The +ask_sessions+ table
|
|
9
|
+
# stores session state in a JSONB +data+ column, keyed by a unique
|
|
10
|
+
# +session_id+ string.
|
|
11
|
+
#
|
|
12
|
+
# Used automatically when +persistence_adapter+ is configured with the
|
|
13
|
+
# default model class:
|
|
14
|
+
#
|
|
15
|
+
# Ask::Rails::Harness.configure do |config|
|
|
16
|
+
# config.persistence_adapter = Ask::Rails::Harness::Persistence.new
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Without +model_class:+, +Persistence+ defaults to +Ask::Rails::Harness::Session+.
|
|
20
|
+
class Session < ActiveRecord::Base
|
|
21
|
+
self.table_name = "ask_sessions"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
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-harness-data",
|
|
6
|
+
data: { sessions: @sessions.to_json, active_session_id: @active_session&.dig(:id) } %>
|