agentf 0.3.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/bin/agentf +8 -0
- data/lib/agentf/agent_policy.rb +54 -0
- data/lib/agentf/agents/architect.rb +67 -0
- data/lib/agentf/agents/base.rb +53 -0
- data/lib/agentf/agents/debugger.rb +75 -0
- data/lib/agentf/agents/designer.rb +69 -0
- data/lib/agentf/agents/documenter.rb +58 -0
- data/lib/agentf/agents/explorer.rb +65 -0
- data/lib/agentf/agents/reviewer.rb +64 -0
- data/lib/agentf/agents/security.rb +84 -0
- data/lib/agentf/agents/specialist.rb +68 -0
- data/lib/agentf/agents/tester.rb +79 -0
- data/lib/agentf/agents.rb +19 -0
- data/lib/agentf/cli/architecture.rb +83 -0
- data/lib/agentf/cli/arg_parser.rb +50 -0
- data/lib/agentf/cli/code.rb +165 -0
- data/lib/agentf/cli/install.rb +112 -0
- data/lib/agentf/cli/memory.rb +393 -0
- data/lib/agentf/cli/metrics.rb +103 -0
- data/lib/agentf/cli/router.rb +111 -0
- data/lib/agentf/cli/update.rb +204 -0
- data/lib/agentf/commands/architecture.rb +183 -0
- data/lib/agentf/commands/debugger.rb +238 -0
- data/lib/agentf/commands/designer.rb +179 -0
- data/lib/agentf/commands/explorer.rb +208 -0
- data/lib/agentf/commands/memory_reviewer.rb +186 -0
- data/lib/agentf/commands/metrics.rb +272 -0
- data/lib/agentf/commands/security_scanner.rb +98 -0
- data/lib/agentf/commands/tester.rb +232 -0
- data/lib/agentf/commands.rb +17 -0
- data/lib/agentf/context_builder.rb +35 -0
- data/lib/agentf/installer.rb +580 -0
- data/lib/agentf/mcp/server.rb +310 -0
- data/lib/agentf/memory.rb +530 -0
- data/lib/agentf/packs.rb +74 -0
- data/lib/agentf/service/providers.rb +158 -0
- data/lib/agentf/tools/component_spec.rb +28 -0
- data/lib/agentf/tools/error_analysis.rb +19 -0
- data/lib/agentf/tools/file_match.rb +21 -0
- data/lib/agentf/tools/test_template.rb +17 -0
- data/lib/agentf/tools.rb +12 -0
- data/lib/agentf/version.rb +5 -0
- data/lib/agentf/workflow_contract.rb +158 -0
- data/lib/agentf/workflow_engine.rb +424 -0
- data/lib/agentf.rb +87 -0
- metadata +164 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Agentf
|
|
7
|
+
module CLI
|
|
8
|
+
# CLI subcommand for updating provider manifests when the gem version changes.
|
|
9
|
+
#
|
|
10
|
+
# Compares a `.agentf-version` stamp file in each provider directory against
|
|
11
|
+
# `Agentf::VERSION`. If the stamp is missing or outdated, re-runs the
|
|
12
|
+
# installer and writes the new stamp. Skips when already current unless
|
|
13
|
+
# `--force` is given.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# agentf update
|
|
17
|
+
# agentf update --force
|
|
18
|
+
# agentf update --provider=opencode,copilot --scope=local
|
|
19
|
+
class Update
|
|
20
|
+
include ArgParser
|
|
21
|
+
|
|
22
|
+
# Maps each provider to the directory where its stamp file lives.
|
|
23
|
+
STAMP_DIRS = {
|
|
24
|
+
"opencode" => ".opencode",
|
|
25
|
+
"copilot" => ".github"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
STAMP_FILENAME = ".agentf-version"
|
|
29
|
+
|
|
30
|
+
def initialize(installer_class: Agentf::Installer)
|
|
31
|
+
@installer_class = installer_class
|
|
32
|
+
@options = {
|
|
33
|
+
providers: ["opencode"],
|
|
34
|
+
scope: "all",
|
|
35
|
+
global_root: Dir.home,
|
|
36
|
+
local_root: Dir.pwd,
|
|
37
|
+
force: false
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run(args)
|
|
42
|
+
if args.include?("help") || args.include?("--help") || args.include?("-h")
|
|
43
|
+
show_help
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
parse_args(args)
|
|
48
|
+
|
|
49
|
+
roots = roots_for(@options[:scope])
|
|
50
|
+
any_updated = false
|
|
51
|
+
|
|
52
|
+
@options[:providers].each do |provider|
|
|
53
|
+
roots.each do |root|
|
|
54
|
+
updated = update_provider(provider: provider, root: root)
|
|
55
|
+
any_updated = true if updated
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
puts "\nAlready up to date." unless any_updated
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def parse_args(args)
|
|
65
|
+
@options[:force] = !args.delete("--force").nil?
|
|
66
|
+
|
|
67
|
+
provider_val = parse_single_option(args, "--provider=") || parse_single_option(args, "-p=")
|
|
68
|
+
if provider_val
|
|
69
|
+
providers = provider_val.split(",").map { |item| item.strip.downcase }.reject(&:empty?)
|
|
70
|
+
@options[:providers] = providers == ["all"] ? Agentf::Installer::PROVIDER_LAYOUTS.keys : providers
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
scope_val = parse_single_option(args, "--scope=") || parse_single_option(args, "-s=")
|
|
74
|
+
@options[:scope] = scope_val.downcase if scope_val
|
|
75
|
+
|
|
76
|
+
global_root = parse_single_option(args, "--global-root=")
|
|
77
|
+
@options[:global_root] = File.expand_path(global_root) if global_root
|
|
78
|
+
|
|
79
|
+
local_root = parse_single_option(args, "--local-root=")
|
|
80
|
+
@options[:local_root] = File.expand_path(local_root) if local_root
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def roots_for(scope)
|
|
84
|
+
case scope
|
|
85
|
+
when "global"
|
|
86
|
+
[@options[:global_root]]
|
|
87
|
+
when "local"
|
|
88
|
+
[@options[:local_root]]
|
|
89
|
+
else
|
|
90
|
+
[@options[:global_root], @options[:local_root]]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def update_provider(provider:, root:)
|
|
95
|
+
stamp_dir = STAMP_DIRS.fetch(provider) do
|
|
96
|
+
$stderr.puts "Unknown provider: #{provider}"
|
|
97
|
+
return false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
stamp_path = File.join(root, stamp_dir, STAMP_FILENAME)
|
|
101
|
+
installed_version = read_stamp(stamp_path)
|
|
102
|
+
|
|
103
|
+
if installed_version == Agentf::VERSION && !@options[:force]
|
|
104
|
+
puts "#{provider} (#{shorten(root)}): up to date (v#{Agentf::VERSION})"
|
|
105
|
+
return false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
migrate_old_files(root, provider) if @options[:force]
|
|
109
|
+
|
|
110
|
+
action = installed_version ? "Updating #{installed_version} -> #{Agentf::VERSION}" : "Installing v#{Agentf::VERSION}"
|
|
111
|
+
action = "Force reinstalling v#{Agentf::VERSION}" if @options[:force] && installed_version == Agentf::VERSION
|
|
112
|
+
puts "#{provider} (#{shorten(root)}): #{action}"
|
|
113
|
+
|
|
114
|
+
installer = @installer_class.new(
|
|
115
|
+
global_root: root,
|
|
116
|
+
local_root: root
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
results = installer.install(
|
|
120
|
+
providers: [provider],
|
|
121
|
+
scope: "local"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
results.each do |result|
|
|
125
|
+
puts " #{result.fetch('status').upcase}: #{Pathname.new(result.fetch('path')).cleanpath}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
write_stamp(stamp_path, Agentf::VERSION)
|
|
129
|
+
puts " Stamp: #{Agentf::VERSION} -> #{shorten(stamp_path)}"
|
|
130
|
+
true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def migrate_old_files(root, provider)
|
|
134
|
+
return unless provider == "opencode"
|
|
135
|
+
|
|
136
|
+
opencode_dir = File.join(root, ".opencode")
|
|
137
|
+
return unless Dir.exist?(opencode_dir)
|
|
138
|
+
|
|
139
|
+
old_files = [
|
|
140
|
+
File.join(opencode_dir, "tools", "agentf-tools.ts"),
|
|
141
|
+
File.join(opencode_dir, "agents", "WORKFLOW_ENGINE.md"),
|
|
142
|
+
File.join(opencode_dir, "memory", "REDIS_SCHEMA.md")
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
old_agent_names = %w[EXPLORER ARCHITECT DESIGNER DEBUGGER REVIEWER TESTER DOCUMENTER SECURITY SPECIALIST]
|
|
146
|
+
old_files.concat(old_agent_names.map { |name| File.join(opencode_dir, "agents", "#{name}.md") })
|
|
147
|
+
|
|
148
|
+
old_command_names = %w[explorer tester metrics security_scanner memory_reviewer designer debugger architecture]
|
|
149
|
+
old_files.concat(old_command_names.map { |name| File.join(opencode_dir, "commands", "#{name}.md") })
|
|
150
|
+
|
|
151
|
+
removed_count = 0
|
|
152
|
+
old_files.each do |file|
|
|
153
|
+
next unless File.exist?(file)
|
|
154
|
+
|
|
155
|
+
File.delete(file)
|
|
156
|
+
puts " REMOVED: #{shorten(file)}"
|
|
157
|
+
removed_count += 1
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
puts " Migration: removed #{removed_count} old files" if removed_count > 0
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def read_stamp(path)
|
|
164
|
+
return nil unless File.exist?(path)
|
|
165
|
+
|
|
166
|
+
File.read(path).strip
|
|
167
|
+
rescue SystemCallError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def write_stamp(path, version)
|
|
172
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
173
|
+
File.write(path, "#{version}\n")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def shorten(path)
|
|
177
|
+
home = Dir.home
|
|
178
|
+
path.start_with?(home) ? path.sub(home, "~") : path
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def show_help
|
|
182
|
+
puts <<~HELP
|
|
183
|
+
Usage: agentf update [options]
|
|
184
|
+
|
|
185
|
+
Regenerates provider manifests when the agentf gem version changes.
|
|
186
|
+
Compares a .agentf-version stamp file against the current version.
|
|
187
|
+
Skips providers that are already up to date unless --force is used.
|
|
188
|
+
|
|
189
|
+
Options:
|
|
190
|
+
--provider=LIST Providers: opencode,copilot,all (default: opencode)
|
|
191
|
+
--scope=SCOPE Update scope: global|local|all (default: all)
|
|
192
|
+
--global-root=PATH Root for global installs (default: $HOME)
|
|
193
|
+
--local-root=PATH Root for local installs (default: current directory)
|
|
194
|
+
--force Regenerate even if version matches
|
|
195
|
+
|
|
196
|
+
Examples:
|
|
197
|
+
agentf update
|
|
198
|
+
agentf update --force
|
|
199
|
+
agentf update --provider=opencode,copilot --scope=local
|
|
200
|
+
HELP
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Agentf
|
|
6
|
+
module Commands
|
|
7
|
+
class Architecture
|
|
8
|
+
NAME = "architecture"
|
|
9
|
+
|
|
10
|
+
def self.manifest
|
|
11
|
+
{
|
|
12
|
+
"name" => NAME,
|
|
13
|
+
"description" => "Analyze architecture layers, callback risks, and incremental adoption opportunities.",
|
|
14
|
+
"commands" => [
|
|
15
|
+
{ "name" => "analyze_layers", "type" => "function" },
|
|
16
|
+
{ "name" => "analyze_callbacks", "type" => "function" },
|
|
17
|
+
{ "name" => "find_god_objects", "type" => "function" },
|
|
18
|
+
{ "name" => "review_layer_violations", "type" => "function" },
|
|
19
|
+
{ "name" => "plan_gradual_adoption", "type" => "function" }
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(base_path: nil)
|
|
25
|
+
@base_path = Pathname.new(base_path || Agentf.config.base_path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def analyze_layers
|
|
29
|
+
files = @base_path.glob("**/*.rb").select(&:file?)
|
|
30
|
+
buckets = {
|
|
31
|
+
"models" => 0,
|
|
32
|
+
"controllers" => 0,
|
|
33
|
+
"services" => 0,
|
|
34
|
+
"queries" => 0,
|
|
35
|
+
"presenters" => 0,
|
|
36
|
+
"concerns" => 0,
|
|
37
|
+
"other" => 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
files.each do |file|
|
|
41
|
+
rel = file.relative_path_from(@base_path).to_s
|
|
42
|
+
bucket = classify_layer(rel)
|
|
43
|
+
buckets[bucket] += 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
"total_files" => files.length,
|
|
48
|
+
"layers" => buckets,
|
|
49
|
+
"layer_balance_score" => layer_balance_score(buckets)
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def analyze_callbacks(limit: 20)
|
|
54
|
+
files = @base_path.glob("app/models/**/*.rb").select(&:file?)
|
|
55
|
+
findings = []
|
|
56
|
+
|
|
57
|
+
files.each do |file|
|
|
58
|
+
text = safe_read(file)
|
|
59
|
+
count = text.scan(/\b(before|after|around)_(save|create|update|validation|commit|destroy)\b/).length
|
|
60
|
+
next if count.zero?
|
|
61
|
+
|
|
62
|
+
findings << {
|
|
63
|
+
"file" => file.relative_path_from(@base_path).to_s,
|
|
64
|
+
"callbacks" => count,
|
|
65
|
+
"risk" => count >= 4 ? "high" : (count >= 2 ? "medium" : "low")
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sorted = findings.sort_by { |item| -item["callbacks"] }.first(limit)
|
|
70
|
+
{ "count" => sorted.length, "findings" => sorted }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def find_god_objects(limit: 20)
|
|
74
|
+
files = @base_path.glob("app/**/*.rb").select(&:file?)
|
|
75
|
+
findings = files.map do |file|
|
|
76
|
+
text = safe_read(file)
|
|
77
|
+
methods = text.scan(/^\s*def\s+/).length
|
|
78
|
+
lines = text.lines.length
|
|
79
|
+
score = (methods * lines).to_i
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
"file" => file.relative_path_from(@base_path).to_s,
|
|
83
|
+
"methods" => methods,
|
|
84
|
+
"lines" => lines,
|
|
85
|
+
"score" => score
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
top = findings.sort_by { |item| -item["score"] }.first(limit)
|
|
90
|
+
{ "count" => top.length, "findings" => top }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def review_layer_violations(limit: 50)
|
|
94
|
+
files = @base_path.glob("app/**/*.rb").select(&:file?)
|
|
95
|
+
violations = []
|
|
96
|
+
|
|
97
|
+
files.each do |file|
|
|
98
|
+
rel = file.relative_path_from(@base_path).to_s
|
|
99
|
+
text = safe_read(file)
|
|
100
|
+
|
|
101
|
+
if rel.start_with?("app/models/") && text.include?("render ")
|
|
102
|
+
violations << violation(rel, "model_renders_view", "Model references rendering concerns")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if rel.start_with?("app/controllers/") && text.include?("ActiveRecord::Base")
|
|
106
|
+
violations << violation(rel, "controller_raw_active_record", "Controller references ActiveRecord::Base directly")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if rel.start_with?("app/services/") && text.include?("params[")
|
|
110
|
+
violations << violation(rel, "service_params_coupling", "Service appears tightly coupled to controller params")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
"count" => [violations.length, limit].min,
|
|
116
|
+
"violations" => violations.first(limit)
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def plan_gradual_adoption(goal: "improve architecture boundaries")
|
|
121
|
+
layer_report = analyze_layers
|
|
122
|
+
callback_report = analyze_callbacks(limit: 5)
|
|
123
|
+
god_report = find_god_objects(limit: 5)
|
|
124
|
+
|
|
125
|
+
steps = [
|
|
126
|
+
"Baseline current architecture metrics and annotate high-risk hotspots.",
|
|
127
|
+
"Prioritize top callback-heavy models and extract one concern/service seam at a time.",
|
|
128
|
+
"Refactor top god objects behind explicit boundaries with tests per extraction.",
|
|
129
|
+
"Add review gate checks to prevent reintroduction of known violations.",
|
|
130
|
+
"Track trend metrics weekly and iterate toward target layer balance."
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
{
|
|
134
|
+
"goal" => goal,
|
|
135
|
+
"baseline" => {
|
|
136
|
+
"layers" => layer_report,
|
|
137
|
+
"callbacks" => callback_report,
|
|
138
|
+
"god_objects" => god_report
|
|
139
|
+
},
|
|
140
|
+
"steps" => steps
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def classify_layer(path)
|
|
147
|
+
return "models" if path.start_with?("app/models/")
|
|
148
|
+
return "controllers" if path.start_with?("app/controllers/")
|
|
149
|
+
return "services" if path.start_with?("app/services/")
|
|
150
|
+
return "queries" if path.include?("/queries/")
|
|
151
|
+
return "presenters" if path.include?("/presenters/")
|
|
152
|
+
return "concerns" if path.include?("/concerns/")
|
|
153
|
+
|
|
154
|
+
"other"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def layer_balance_score(buckets)
|
|
158
|
+
core = %w[models controllers services queries presenters concerns]
|
|
159
|
+
values = core.map { |key| buckets[key] }
|
|
160
|
+
max = values.max.to_f
|
|
161
|
+
min = values.min.to_f
|
|
162
|
+
return 1.0 if max.zero?
|
|
163
|
+
|
|
164
|
+
(1.0 - ((max - min) / max)).round(4)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def safe_read(path)
|
|
168
|
+
path.read
|
|
169
|
+
rescue StandardError
|
|
170
|
+
""
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def violation(file, code, message)
|
|
174
|
+
{
|
|
175
|
+
"file" => file,
|
|
176
|
+
"code" => code,
|
|
177
|
+
"message" => message,
|
|
178
|
+
"severity" => "warn"
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Agentf
|
|
6
|
+
module Commands
|
|
7
|
+
class Debugger
|
|
8
|
+
NAME = "debugger"
|
|
9
|
+
|
|
10
|
+
def self.manifest
|
|
11
|
+
{
|
|
12
|
+
"name" => NAME,
|
|
13
|
+
"description" => "Parse errors, analyze logs, and suggest fixes for bugs.",
|
|
14
|
+
"commands" => [
|
|
15
|
+
{ "name" => "parse_error", "type" => "function" },
|
|
16
|
+
{ "name" => "analyze_logs", "type" => "function" },
|
|
17
|
+
{ "name" => "suggest_fix", "type" => "function" },
|
|
18
|
+
{ "name" => "cluster_errors", "type" => "function" }
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ERROR_PATTERNS = {
|
|
24
|
+
"ImportError" => {
|
|
25
|
+
"possible_causes" => [
|
|
26
|
+
"Module not installed",
|
|
27
|
+
"Wrong import path",
|
|
28
|
+
"Circular import",
|
|
29
|
+
"Missing __init__.py"
|
|
30
|
+
],
|
|
31
|
+
"fix_template" => "Check package installation and import path"
|
|
32
|
+
},
|
|
33
|
+
"TypeError" => {
|
|
34
|
+
"possible_causes" => [
|
|
35
|
+
"Passing wrong type to function",
|
|
36
|
+
"Undefined variable",
|
|
37
|
+
"Calling non-callable"
|
|
38
|
+
],
|
|
39
|
+
"fix_template" => "Verify argument types match function signature"
|
|
40
|
+
},
|
|
41
|
+
"NoMethodError" => {
|
|
42
|
+
"possible_causes" => [
|
|
43
|
+
"Method not defined",
|
|
44
|
+
"Typo in method name",
|
|
45
|
+
"Wrong object type"
|
|
46
|
+
],
|
|
47
|
+
"fix_template" => "Check method exists on object"
|
|
48
|
+
},
|
|
49
|
+
"NameError" => {
|
|
50
|
+
"possible_causes" => [
|
|
51
|
+
"Variable not defined",
|
|
52
|
+
"Using before declaration",
|
|
53
|
+
"Scope issue"
|
|
54
|
+
],
|
|
55
|
+
"fix_template" => "Check variable is defined before use"
|
|
56
|
+
},
|
|
57
|
+
"SyntaxError" => {
|
|
58
|
+
"possible_causes" => [
|
|
59
|
+
"Missing bracket/parenthesis",
|
|
60
|
+
"Invalid syntax",
|
|
61
|
+
"Indentation error"
|
|
62
|
+
],
|
|
63
|
+
"fix_template" => "Check syntax at error location"
|
|
64
|
+
},
|
|
65
|
+
"NetworkError" => {
|
|
66
|
+
"possible_causes" => [
|
|
67
|
+
"API endpoint down",
|
|
68
|
+
"Network connectivity",
|
|
69
|
+
"CORS issue",
|
|
70
|
+
"Timeout"
|
|
71
|
+
],
|
|
72
|
+
"fix_template" => "Verify API endpoint and network connectivity"
|
|
73
|
+
}
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
def initialize(base_path: nil)
|
|
77
|
+
@base_path = base_path || Agentf.config.base_path
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Parse error text and extract structured information
|
|
81
|
+
def parse_error(error_text)
|
|
82
|
+
error_type = "Unknown"
|
|
83
|
+
message = error_text
|
|
84
|
+
location = "unknown"
|
|
85
|
+
|
|
86
|
+
# Try Ruby pattern
|
|
87
|
+
if (match = error_text.match(/(?<type>\w+(?:Error|Exception)):\s*(?<message>[^\n]+?)(?:\s+from\s+(?<file>[^:]+):(?<line>\d+))?$/m))
|
|
88
|
+
error_type = match[:type]
|
|
89
|
+
message = match[:message].strip
|
|
90
|
+
location = "#{match[:file]}:#{match[:line]}" if match[:file]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Try Python pattern
|
|
94
|
+
if error_type == "Unknown" && (match = error_text.match(/(\w+Error):\s*([^\n]+?)(?:\s+File\s+"([^"]+)",\s+line\s+(\d+))?$/m))
|
|
95
|
+
error_type = match[1]
|
|
96
|
+
message = match[2].strip
|
|
97
|
+
location = "#{match[3]}:#{match[4]}" if match[3]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Try JS pattern
|
|
101
|
+
if error_type == "Unknown" && (match = error_text.match(/(\w+Error):\s*([^\n]+?)(?:\s+at\s+.+?\s+\((.+?):(\d+):(\d+)\))?$/m))
|
|
102
|
+
error_type = match[1]
|
|
103
|
+
message = match[2].strip
|
|
104
|
+
location = "#{match[3]}:#{match[4]}" if match[3]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
error_info = ERROR_PATTERNS[error_type] || {
|
|
108
|
+
"possible_causes" => ["Unknown error source"],
|
|
109
|
+
"fix_template" => "Investigate error context"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
stack_trace = parse_stack_trace(error_text)
|
|
113
|
+
|
|
114
|
+
Agentf::Tools::ErrorAnalysis.new(
|
|
115
|
+
error_type: error_type,
|
|
116
|
+
message: message[0..199],
|
|
117
|
+
location: location,
|
|
118
|
+
possible_causes: error_info["possible_causes"],
|
|
119
|
+
suggested_fix: error_info["fix_template"],
|
|
120
|
+
stack_trace: stack_trace
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Analyze log files for errors/warnings
|
|
125
|
+
def analyze_logs(log_file: nil, num_lines: 100)
|
|
126
|
+
errors = []
|
|
127
|
+
warnings = []
|
|
128
|
+
|
|
129
|
+
log_paths = []
|
|
130
|
+
if log_file
|
|
131
|
+
log_paths << Pathname.new(@base_path) + log_file
|
|
132
|
+
else
|
|
133
|
+
log_paths << Pathname.new(@base_path).join("logs", "app.log")
|
|
134
|
+
log_paths << Pathname.new(@base_path).join("log", "app.log")
|
|
135
|
+
log_paths << Pathname.new(@base_path).join("app.log")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
content = ""
|
|
139
|
+
log_paths.each do |path|
|
|
140
|
+
if path.exist?
|
|
141
|
+
lines = path.read.split("\n").last(num_lines)
|
|
142
|
+
content = lines.join("\n")
|
|
143
|
+
break
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
return { "errors" => [], "warnings" => [], "summary" => "No log file found" } if content.empty?
|
|
148
|
+
|
|
149
|
+
content.lines.each do |line|
|
|
150
|
+
errors << line.strip if line =~ /\b(ERROR|Exception|Failed)\b/i
|
|
151
|
+
warnings << line.strip if line =~ /\b(WARN|WARNING)\b/i
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
{
|
|
155
|
+
"errors" => errors.last(20),
|
|
156
|
+
"warnings" => warnings.last(20),
|
|
157
|
+
"summary" => "Found #{errors.size} errors and #{warnings.size} warnings in recent logs"
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Generate fix suggestion based on error analysis
|
|
162
|
+
def suggest_fix(analysis, source_code: nil)
|
|
163
|
+
suggestions = []
|
|
164
|
+
|
|
165
|
+
suggestions << analysis.suggested_fix
|
|
166
|
+
|
|
167
|
+
msg_lower = analysis.message.downcase
|
|
168
|
+
suggestions << "Check for nil values before use" if msg_lower.include?("nil") || msg_lower.include?("none")
|
|
169
|
+
suggestions << "Verify variable is defined before access" if msg_lower.include?("undefined") || msg_lower.include?("not defined")
|
|
170
|
+
suggestions << "Consider increasing timeout or checking service availability" if msg_lower.include?("timeout")
|
|
171
|
+
|
|
172
|
+
suggestions << "Review code at #{analysis.location}" if source_code && analysis.location != "unknown"
|
|
173
|
+
|
|
174
|
+
historical = historical_fix_hints(analysis)
|
|
175
|
+
suggestions.concat(historical) unless historical.empty?
|
|
176
|
+
|
|
177
|
+
suggestions.map { |s| "- #{s}" }.join("\n")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def cluster_errors(errors)
|
|
181
|
+
clusters = Hash.new { |h, k| h[k] = { "count" => 0, "examples" => [] } }
|
|
182
|
+
|
|
183
|
+
Array(errors).each do |error_text|
|
|
184
|
+
analysis = parse_error(error_text.to_s)
|
|
185
|
+
key = analysis.error_type
|
|
186
|
+
clusters[key]["count"] += 1
|
|
187
|
+
clusters[key]["examples"] << analysis.message if clusters[key]["examples"].length < 3
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
clusters
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def parse_stack_trace(error_text)
|
|
196
|
+
frames = []
|
|
197
|
+
|
|
198
|
+
# Ruby stack trace
|
|
199
|
+
error_text.scan(/from\s+([^:]+):(\d+):in\s+`(.+?)'/) do |file, line, func|
|
|
200
|
+
frames << { "file" => file, "line" => line, "function" => func }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Python stack trace
|
|
204
|
+
error_text.scan(/File\s+"([^"]+)",\s+line\s+(\d+),\s+in\s+(\w+)/) do |file, line, func|
|
|
205
|
+
frames << { "file" => file, "line" => line, "function" => func }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# JS stack trace
|
|
209
|
+
error_text.scan(/at\s+(?:(\w+)\s+)?\(?(.+?):(\d+):(\d+)\)?/) do |func, file, line, _col|
|
|
210
|
+
frames << { "function" => func || "anonymous", "file" => file, "line" => line }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
frames
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def historical_fix_hints(analysis)
|
|
217
|
+
memory = Agentf::Memory::RedisMemory.new(project: Agentf.config.project_name)
|
|
218
|
+
incidents = memory.get_memories_by_type(type: "incident", limit: 25)
|
|
219
|
+
return [] if incidents.empty?
|
|
220
|
+
|
|
221
|
+
matched = incidents.select do |incident|
|
|
222
|
+
haystack = [incident["title"], incident["description"], incident["context"]].compact.join(" ").downcase
|
|
223
|
+
haystack.include?(analysis.error_type.downcase)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
return [] if matched.empty?
|
|
227
|
+
|
|
228
|
+
matched.first(2).map do |incident|
|
|
229
|
+
"Try prior incident fix: #{incident['title']}"
|
|
230
|
+
end
|
|
231
|
+
rescue StandardError
|
|
232
|
+
[]
|
|
233
|
+
ensure
|
|
234
|
+
memory&.close
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|