ollama_agent 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73ed8cb32bcabdd008dee785606c96aefc9bf626a3c3a1187b7bab8ef68c9627
4
- data.tar.gz: 150f32efcba0bd8602cd571654bcfc8f42b99af56873a57d792202bb957b4701
3
+ metadata.gz: cfef6ceb699a061d21e17bf98b94cd78ae9f25b9a0560aebdc8157bc09f87bb8
4
+ data.tar.gz: b9fbbc92044a421304822f26f25fdb502dbf860686c3e576aa65ec5cf488bae8
5
5
  SHA512:
6
- metadata.gz: 18ce5ec435f2b8330e2684dd116bd33b9e399d93857a5469365902a19a3f99b61bf7d227df746b5bbe82d7b77c4f952b3e2ad8ac61b204498e4d07f469978de0
7
- data.tar.gz: fd59f660457f2de80e949a098283b38eeccb94cb9daa757158d76d3f457a1f0926ef30f5f5e98ba864ef7504470842775c971ed0a373f48ba8e26b6b0ecdc129
6
+ metadata.gz: f7ca6e5d68e52c682c33503f49629a9709b23238387b8b7cc5dc2622e3f809db08c8e21a43dc80df55aae025895e9485701a03a19f9dcfbc920807bc4f551e92
7
+ data.tar.gz: f4aa8a0a648f42e2b2ad6f4a4adf180d5a7a42b8bb99af279d3c7aa407c96fe0ca7e59234da0ba172a3e1f132c13cad6110ea435ca9b6f8026dc559fd7541f43
data/README.md CHANGED
@@ -11,13 +11,23 @@ Ruby gem that runs a **CLI coding agent** against a local [Ollama](https://ollam
11
11
  - Tool `search_code` – search code with ripgrep or grep.
12
12
  - Tool `edit_file` – apply unified diffs safely.
13
13
  - CLI built with Thor, entry point `exe/ollama_agent`.
14
+ - **`self_review`** – self-review / improvement with a **`--mode`**:
15
+ - **`analysis`** (default, alias `1`) — read-only tools; report only; no writes.
16
+ - **`interactive`** (alias `2`, `fix`) — full tools on `--root`; you confirm each patch (like `ask`); optional `-y` / `--semi`.
17
+ - **`automated`** (alias `3`, `sandbox`) — temp copy, agent edits, **`bundle exec rspec`** in the sandbox, optional **`--apply`** to merge into your checkout.
18
+ - **`improve`** — same as **`self_review --mode automated`** (you can pass **`--mode automated`** explicitly; other modes belong on **`self_review`**).
14
19
 
15
20
  ## Requirements
16
21
 
17
- - Ruby ≥ 3.2
22
+ - Ruby ≥ 3.2 (enforced in the gemspec as `required_ruby_version`)
18
23
  - **Local:** Ollama running and a capable tool-calling model, **or**
19
24
  - **Ollama Cloud:** API key and a cloud-capable model name (see below)
20
25
 
26
+ ### Prerequisites (external tools)
27
+
28
+ - **`patch`** — required for `edit_file` (GNU `patch` on `PATH`). On Windows, use Git Bash, WSL, GnuWin32, or another environment that provides `patch`.
29
+ - **`rg` (ripgrep) or `grep`** — text mode for `search_code` needs at least one of these on `PATH` (ripgrep is preferred when present).
30
+
21
31
  ## Installation
22
32
 
23
33
  From RubyGems (when published) or from this repository:
@@ -54,6 +64,23 @@ Interactive REPL:
54
64
  bundle exec ruby exe/ollama_agent ask --interactive
55
65
  ```
56
66
 
67
+ Self-review modes (default project root is the loaded gem tree unless you set `--root` or `OLLAMA_AGENT_ROOT`):
68
+
69
+ ```bash
70
+ # Mode 1 — analysis only (default)
71
+ bundle exec ruby exe/ollama_agent self_review
72
+ bundle exec ruby exe/ollama_agent self_review --mode analysis
73
+
74
+ # Mode 2 — optional fixes in the working tree (confirm each patch, or -y / --semi)
75
+ bundle exec ruby exe/ollama_agent self_review --mode interactive
76
+
77
+ # Mode 3 — sandbox + tests + optional merge back (same as `improve`)
78
+ bundle exec ruby exe/ollama_agent self_review --mode automated
79
+ bundle exec ruby exe/ollama_agent improve --apply
80
+ ```
81
+
82
+ For mode 3, `-y` skips all patch prompts; `--no-semi` prompts for every patch when not using `-y`.
83
+
57
84
  With a **thinking-capable** model, enable reasoning output:
58
85
 
59
86
  ```bash
@@ -97,6 +124,7 @@ bundle exec ruby exe/ollama_agent ask "Your task"
97
124
  | `OLLAMA_AGENT_MARKDOWN` | Set to `0` to disable Markdown formatting of assistant replies (plain text only) |
98
125
  | `OLLAMA_AGENT_THINKING_MARKDOWN` | Set to `1` to render **thinking** text with Markdown (muted); default is plain dim text inside the Thinking frame |
99
126
  | `OLLAMA_AGENT_THINK` | Model **thinking** mode for compatible models: `true` / `false`, or `high` / `medium` / `low` (see ollama-client `think:`). Empty = omit (server default). |
127
+ | `OLLAMA_AGENT_PATCH_RISK_MAX_DIFF_LINES` | Max changed-line count before a diff is treated as "large" for semi-auto patch risk (default **80**) |
100
128
  | `OLLAMA_AGENT_INDEX_REBUILD` | Set to `1` to drop the cached Prism Ruby index before the next symbol search in this process |
101
129
  | `OLLAMA_AGENT_RUBY_INDEX_MAX_FILES` | Max `.rb` files to parse per index build (default **5000**) |
102
130
  | `OLLAMA_AGENT_RUBY_INDEX_MAX_FILE_BYTES` | Skip Ruby files larger than this many bytes (default **512000**) |
@@ -11,6 +11,7 @@ require_relative "tool_content_parser"
11
11
 
12
12
  module OllamaAgent
13
13
  # Runs a tool-calling loop against Ollama: read files, search, apply unified diffs.
14
+ # rubocop:disable Metrics/ClassLength -- Facade for chat loop, tools, and HTTP client wiring
14
15
  class Agent
15
16
  include SandboxedTools
16
17
 
@@ -21,10 +22,13 @@ module OllamaAgent
21
22
  attr_reader :client, :root
22
23
 
23
24
  # rubocop:disable Metrics/ParameterLists -- CLI and tests pass explicit dependencies
24
- def initialize(client: nil, model: nil, root: nil, confirm_patches: true, http_timeout: nil, think: nil)
25
+ def initialize(client: nil, model: nil, root: nil, confirm_patches: true, http_timeout: nil, think: nil,
26
+ read_only: false, patch_policy: nil)
25
27
  @model = model || default_model
26
28
  @root = File.expand_path(root || ENV.fetch("OLLAMA_AGENT_ROOT", Dir.pwd))
27
29
  @confirm_patches = confirm_patches
30
+ @read_only = read_only
31
+ @patch_policy = patch_policy
28
32
  @http_timeout_override = http_timeout
29
33
  @think = think
30
34
  @client = client || build_default_client
@@ -82,7 +86,7 @@ module OllamaAgent
82
86
  def chat_request_args(messages)
83
87
  args = {
84
88
  messages: messages,
85
- tools: TOOLS,
89
+ tools: @read_only ? READ_ONLY_TOOLS : TOOLS,
86
90
  model: @model,
87
91
  options: { temperature: 0.2 }
88
92
  }
@@ -122,6 +126,8 @@ module OllamaAgent
122
126
  end
123
127
 
124
128
  def system_prompt
129
+ return AgentPrompt.self_review_text if @read_only
130
+
125
131
  AgentPrompt.text
126
132
  end
127
133
 
@@ -143,4 +149,5 @@ module OllamaAgent
143
149
  msg
144
150
  end
145
151
  end
152
+ # rubocop:enable Metrics/ClassLength
146
153
  end
@@ -40,5 +40,20 @@ module OllamaAgent
40
40
  them as the assistant message.
41
41
  PROMPT
42
42
  end
43
+
44
+ def self.self_review_text
45
+ <<~PROMPT
46
+ You are reviewing the ollama_agent Ruby gem. Tools available: list_files, read_file, search_code only.
47
+ Do not call edit_file and do not output unified diffs—this run is analysis-only.
48
+
49
+ Work only under the project root. Briefly state your plan, then use tools.
50
+
51
+ Large Ruby trees: use search_code with mode "method", "class", "module", or "constant" to locate definitions
52
+ via the Prism index, then read_file with start_line/end_line for only the lines you need.
53
+
54
+ Final reply: strengths, risks, and concrete suggestions with file paths (and line numbers when clear).
55
+ Do not paste JSON tool calls in prose; tools run only via native tool calls from the API.
56
+ PROMPT
57
+ end
43
58
  end
44
59
  end
@@ -6,7 +6,12 @@ require_relative "agent"
6
6
 
7
7
  module OllamaAgent
8
8
  # Thor CLI for single-shot and interactive agent sessions.
9
+ # rubocop:disable Metrics/ClassLength -- Thor commands and shared helpers
9
10
  class CLI < Thor
11
+ def self.exit_on_failure?
12
+ true
13
+ end
14
+
10
15
  desc "ask [QUERY]", "Run a natural-language task (reads, search, patch)"
11
16
  method_option :model, type: :string, desc: "Ollama model (default: OLLAMA_AGENT_MODEL or ollama-client default)"
12
17
  method_option :interactive, type: :boolean, aliases: "-i", desc: "Interactive REPL"
@@ -27,12 +32,164 @@ module OllamaAgent
27
32
  end
28
33
  end
29
34
 
35
+ desc "self_review", "Self-review / improvement: --mode analysis | interactive | automated (see help)"
36
+ method_option :mode, type: :string, default: "analysis",
37
+ desc: "analysis (1)=read-only report; interactive (2)=confirm patches in tree; " \
38
+ "automated (3)=sandbox+RSpec+optional --apply"
39
+ long_desc <<~HELP
40
+ Modes:
41
+ analysis Read-only tools; prints a report; no writes (default).
42
+ interactive Full tools on --root; you confirm each patch (like `ask`); optional -y / --semi.
43
+ automated Temp sandbox, agent edits, `bundle exec rspec`; optional --apply to merge back.
44
+
45
+ Aliases: 1/2/3, readonly, fix, confirm, sandbox, full.
46
+ HELP
47
+ method_option :model, type: :string, desc: "Ollama model (default: OLLAMA_AGENT_MODEL or ollama-client default)"
48
+ method_option :root, type: :string, desc: "Project root (default: OLLAMA_AGENT_ROOT or gem root)"
49
+ method_option :timeout, type: :numeric, aliases: "-t", desc: "HTTP timeout seconds (default 120)"
50
+ method_option :think, type: :string, desc: "Thinking mode: true|false|high|medium|low (see OLLAMA_AGENT_THINK)"
51
+ method_option :yes, type: :boolean, aliases: "-y",
52
+ desc: "interactive/automated: apply patches without confirmation"
53
+ method_option :semi, type: :boolean, default: true,
54
+ desc: "interactive/automated: auto-approve obvious patches; prompt for risky (default: true)"
55
+ method_option :apply, type: :boolean, default: false,
56
+ desc: "automated only: after green tests, copy changed files from sandbox into --root"
57
+ def self_review
58
+ dispatch_self_review_mode(SelfImprovement::Modes.normalize(options[:mode]))
59
+ end
60
+
61
+ desc "improve", "Shortcut for: self_review --mode automated"
62
+ method_option :mode, type: :string, default: "automated",
63
+ desc: "Optional; must be automated (or 3, sandbox, full). Other modes: use self_review --mode"
64
+ method_option :model, type: :string, desc: "Ollama model (default: OLLAMA_AGENT_MODEL or ollama-client default)"
65
+ method_option :root, type: :string, desc: "Source tree to copy and test (default: OLLAMA_AGENT_ROOT or gem root)"
66
+ method_option :timeout, type: :numeric, aliases: "-t", desc: "HTTP timeout seconds (default 120)"
67
+ method_option :think, type: :string, desc: "Thinking mode: true|false|high|medium|low (see OLLAMA_AGENT_THINK)"
68
+ method_option :yes, type: :boolean, aliases: "-y", desc: "Apply all patches without confirmation"
69
+ method_option :semi, type: :boolean, default: true,
70
+ desc: "Without -y: auto-approve obvious patches; prompt for risky (default: true)"
71
+ method_option :apply, type: :boolean, default: false,
72
+ desc: "After green tests, copy changed files from sandbox into --root"
73
+ def improve
74
+ ensure_improve_mode_only_automated!
75
+ dispatch_self_review_mode("automated")
76
+ end
77
+
30
78
  private
31
79
 
80
+ def ensure_improve_mode_only_automated!
81
+ m = SelfImprovement::Modes.normalize(options[:mode])
82
+ return if m == "automated"
83
+
84
+ warn Console.error_line(improve_mode_error_message(m))
85
+ exit 1
86
+ end
87
+
88
+ def improve_mode_error_message(normalized_mode)
89
+ return invalid_improve_mode_message unless SelfImprovement::Modes.valid?(normalized_mode)
90
+
91
+ "Command \"improve\" only runs automated (sandbox) mode. For other modes use: " \
92
+ "ollama_agent self_review --mode analysis | interactive"
93
+ end
94
+
95
+ def invalid_improve_mode_message
96
+ "Invalid --mode for improve: use automated (or 3, sandbox, full). Got: #{options[:mode].inspect}"
97
+ end
98
+
99
+ def dispatch_self_review_mode(mode)
100
+ validate_self_review_mode!(mode)
101
+ send(:"run_mode_#{mode}")
102
+ end
103
+
104
+ def validate_self_review_mode!(mode)
105
+ return if SelfImprovement::Modes.valid?(mode)
106
+
107
+ warn Console.error_line(
108
+ "Invalid --mode: use analysis, interactive, or automated (or 1, 2, 3). Got: #{options[:mode].inspect}"
109
+ )
110
+ exit 1
111
+ end
112
+
113
+ def run_mode_analysis
114
+ agent = Agent.new(
115
+ model: options[:model],
116
+ root: resolved_root_for_self_review,
117
+ read_only: true,
118
+ confirm_patches: false,
119
+ http_timeout: options[:timeout],
120
+ think: options[:think]
121
+ )
122
+ SelfImprovement::Analyzer.new(agent).run
123
+ end
124
+
125
+ def run_mode_interactive
126
+ agent = Agent.new(**interactive_agent_keywords)
127
+ SelfImprovement::Analyzer.new(agent).run(SelfImprovement::Analyzer::INTERACTIVE_PROMPT)
128
+ end
129
+
130
+ def interactive_agent_keywords
131
+ semi = options[:semi] != false
132
+ {
133
+ model: options[:model],
134
+ root: resolved_root_for_self_review,
135
+ read_only: false,
136
+ confirm_patches: !options[:yes],
137
+ patch_policy: semi ? PatchRisk.method(:assess).to_proc : nil,
138
+ http_timeout: options[:timeout],
139
+ think: options[:think]
140
+ }
141
+ end
142
+
143
+ def run_mode_automated
144
+ result = SelfImprovement::Improver.new.run(**improve_run_options)
145
+ report_improve_result(result)
146
+ end
147
+
148
+ def resolved_root_for_self_review
149
+ raw = options[:root] || ENV.fetch("OLLAMA_AGENT_ROOT", nil)
150
+ base = raw.to_s.strip.empty? ? OllamaAgent.gem_root : raw
151
+ File.expand_path(base)
152
+ end
153
+
154
+ def improve_run_options
155
+ {
156
+ model: options[:model],
157
+ root: options[:root] || ENV.fetch("OLLAMA_AGENT_ROOT", nil),
158
+ yes: options[:yes],
159
+ semi: options[:semi] != false,
160
+ apply: options[:apply],
161
+ http_timeout: options[:timeout],
162
+ think: options[:think]
163
+ }
164
+ end
165
+
166
+ def report_improve_result(result)
167
+ unless result[:success]
168
+ warn Console.error_line("Tests failed in sandbox.")
169
+ puts result[:test_output]
170
+ exit 1
171
+ end
172
+
173
+ root = result[:source_root]
174
+ puts "ollama_agent: tests passed in sandbox (#{root})"
175
+ copied = result[:copied_to_source]
176
+ report_improve_copied_files(copied, root)
177
+ puts "ollama_agent: no changed files to copy from sandbox" if options[:apply] && copied.empty?
178
+ end
179
+
180
+ def report_improve_copied_files(copied, root)
181
+ return if copied.empty?
182
+
183
+ puts "ollama_agent: copied #{copied.size} file(s) to #{root}"
184
+ copied.sort.each { |rel| puts " #{rel}" }
185
+ end
186
+
187
+ # Build an Agent for the `ask` command.
188
+ # Use the same root resolution as other commands, falling back to the gem root.
32
189
  def build_agent
33
190
  Agent.new(
34
191
  model: options[:model],
35
- root: options[:root],
192
+ root: resolved_root_for_self_review,
36
193
  confirm_patches: !options[:yes],
37
194
  http_timeout: options[:timeout],
38
195
  think: options[:think]
@@ -70,4 +227,5 @@ module OllamaAgent
70
227
  end
71
228
  end
72
229
  end
230
+ # rubocop:enable Metrics/ClassLength
73
231
  end
@@ -77,7 +77,7 @@ module OllamaAgent
77
77
 
78
78
  <<~MSG.strip
79
79
  A unified diff must list --- a/<path>, then +++ b/<path>, then @@ ... @@ before any changed lines.
80
- Do not put @@ on the line right after --- without a +++ line; use the same order as `git diff`.
80
+ Put a +++ b/<file> line before the first @@ hunk (e.g. +++ b/README.md); do not place @@ immediately after --- alone.
81
81
  MSG
82
82
  end
83
83
 
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ # Classifies a proposed unified diff for semi-automatic patch approval (obvious vs risky).
5
+ module PatchRisk
6
+ FORBIDDEN_PATTERNS = [
7
+ /\beval\s*\(/,
8
+ /`rm\s+-rf/,
9
+ /system\s*\(\s*["']sudo/,
10
+ /\bFile\.delete\b/,
11
+ /\bKernel\.exec\b/
12
+ ].freeze
13
+
14
+ LARGE_DIFF_LINES = 80
15
+
16
+ module_function
17
+
18
+ def large_diff_line_limit
19
+ v = ENV.fetch("OLLAMA_AGENT_PATCH_RISK_MAX_DIFF_LINES", nil)
20
+ return LARGE_DIFF_LINES if v.nil? || v.to_s.strip.empty?
21
+
22
+ Integer(v)
23
+ rescue ArgumentError, TypeError
24
+ LARGE_DIFF_LINES
25
+ end
26
+
27
+ def forbidden?(diff)
28
+ FORBIDDEN_PATTERNS.any? { |pattern| diff.match?(pattern) }
29
+ end
30
+
31
+ # Returns :auto_approve (no prompt) or :require_confirmation (prompt when confirm_patches is on).
32
+ def assess(path, diff)
33
+ relative = path.to_s.tr("\\", "/")
34
+
35
+ return :require_confirmation if risky?(relative, diff)
36
+
37
+ return :auto_approve if obvious_path?(relative)
38
+ return :auto_approve if safe_spec_change?(relative, diff)
39
+
40
+ :require_confirmation
41
+ end
42
+
43
+ def risky?(relative, diff)
44
+ forbidden?(diff) ||
45
+ critical_path?(relative) ||
46
+ large_diff?(diff) ||
47
+ critical_lib_file?(relative)
48
+ end
49
+
50
+ def large_diff?(diff, limit: nil)
51
+ max = limit.nil? ? large_diff_line_limit : limit
52
+ diff.scan(/^[-+][^-+]/).size > max
53
+ end
54
+
55
+ def obvious_path?(relative)
56
+ relative.end_with?(".md") || relative.start_with?("docs/")
57
+ end
58
+
59
+ def safe_spec_change?(relative, diff)
60
+ relative.start_with?("spec/") && !large_diff?(diff, limit: 40)
61
+ end
62
+
63
+ def critical_lib_file?(relative)
64
+ return false unless relative.start_with?("lib/")
65
+
66
+ relative.include?("sandboxed_tools") ||
67
+ relative.include?("patch_support") ||
68
+ relative.end_with?("ollama_agent/agent.rb") ||
69
+ relative.end_with?("ollama_agent/tools_schema.rb")
70
+ end
71
+
72
+ def critical_path?(relative)
73
+ return true if relative.match?(/\A(Gemfile|Gemfile\.lock)\z/)
74
+ return true if relative.end_with?(".gemspec")
75
+ return true if relative == "lib/ollama_agent/version.rb"
76
+ return true if relative.start_with?("exe/")
77
+
78
+ false
79
+ end
80
+ end
81
+ end
@@ -8,6 +8,8 @@ module OllamaAgent
8
8
  private
9
9
 
10
10
  def patch_dry_run(diff)
11
+ return patch_missing_tool_message unless patch_available?
12
+
11
13
  output, status = Open3.capture2e(
12
14
  "patch", "-p1", "-f", "-d", @root, "--dry-run",
13
15
  stdin_data: diff
@@ -65,6 +67,8 @@ module OllamaAgent
65
67
  end
66
68
 
67
69
  def apply_patch(diff)
70
+ return patch_missing_tool_message unless patch_available?
71
+
68
72
  output, status = Open3.capture2e(
69
73
  "patch", "-p1", "-f", "-d", @root,
70
74
  stdin_data: diff
@@ -74,5 +78,26 @@ module OllamaAgent
74
78
 
75
79
  patch_failure_message(output, dry_run: false)
76
80
  end
81
+
82
+ def patch_available?
83
+ return @patch_available if defined?(@patch_available)
84
+
85
+ @patch_available = patch_binary_usable?
86
+ end
87
+
88
+ def patch_binary_usable?
89
+ _, status = Open3.capture2e("patch", "--version")
90
+ return true if status.success?
91
+
92
+ _, which_status = Open3.capture2e("which", "patch")
93
+ which_status.success?
94
+ end
95
+
96
+ def patch_missing_tool_message
97
+ <<~MSG.strip
98
+ Error: ollama_agent: the `patch` program was not found or is not usable on PATH. edit_file requires GNU patch.
99
+ Install it (e.g. apt install patch, brew install gpatch), or on Windows use Git Bash, WSL, or GnuWin32.
100
+ MSG
101
+ end
77
102
  end
78
103
  end
@@ -21,7 +21,10 @@ module OllamaAgent
21
21
  paths = collect_relative_paths(base, cap)
22
22
  return "(no files listed)" if paths.empty?
23
23
 
24
- paths.sort.join("\n")
24
+ body = paths.sort.join("\n")
25
+ return body unless paths.size >= cap
26
+
27
+ "#{body}\n(list truncated at #{cap} entries; pass max_entries or narrow directory)"
25
28
  end
26
29
 
27
30
  def collect_relative_paths(base, cap)
@@ -39,14 +39,25 @@ module OllamaAgent
39
39
 
40
40
  # Rebuild only when OLLAMA_AGENT_INDEX_REBUILD changes (avoids rebuilding on every call while it stays "1").
41
41
  def ruby_index
42
- fingerprint = ENV.fetch("OLLAMA_AGENT_INDEX_REBUILD", "")
43
- @ruby_index = nil if fingerprint != @ruby_index_cache_fingerprint
44
- @ruby_index_cache_fingerprint = fingerprint
42
+ @ruby_index_mutex ||= Mutex.new
43
+ @ruby_index_mutex.synchronize { synchronized_ruby_index }
44
+ end
45
+
46
+ def synchronized_ruby_index
47
+ invalidate_ruby_index_if_fingerprint_changed
45
48
  return @ruby_index if @ruby_index
46
49
 
47
- @ruby_index = RubyIndex.build(root: @root)
48
- warn "ollama_agent: #{@ruby_index.summary_line}" if ENV["OLLAMA_AGENT_DEBUG"] == "1"
49
- @ruby_index
50
+ @ruby_index = RubyIndex.build(root: @root).tap do |idx|
51
+ warn "ollama_agent: #{idx.summary_line}" if ENV["OLLAMA_AGENT_DEBUG"] == "1"
52
+ end
53
+ end
54
+
55
+ def invalidate_ruby_index_if_fingerprint_changed
56
+ fp = ENV.fetch("OLLAMA_AGENT_INDEX_REBUILD", "")
57
+ return if fp == @ruby_index_cache_fingerprint
58
+
59
+ @ruby_index = nil
60
+ @ruby_index_cache_fingerprint = fp
50
61
  end
51
62
  end
52
63
  end
@@ -5,6 +5,7 @@ require "pathname"
5
5
 
6
6
  require_relative "console"
7
7
  require_relative "diff_path_validator"
8
+ require_relative "patch_risk"
8
9
  require_relative "patch_support"
9
10
  require_relative "repo_list"
10
11
  require_relative "ruby_index_tool_support"
@@ -48,10 +49,10 @@ module OllamaAgent
48
49
  pattern = tool_arg(args, "pattern").to_s
49
50
  mode = (tool_arg(args, "mode") || "text").to_s.downcase
50
51
 
51
- return search_code_ruby(pattern, mode) if ruby_search_mode?(mode)
52
-
53
52
  return missing_tool_argument("search_code", "pattern") if blank_tool_value?(pattern)
54
53
 
54
+ return search_code_ruby(pattern, mode) if ruby_search_mode?(mode)
55
+
55
56
  search_code(pattern, tool_arg(args, "directory") || ".")
56
57
  end
57
58
 
@@ -84,7 +85,8 @@ module OllamaAgent
84
85
 
85
86
  def read_file_too_large(abs)
86
87
  n = max_read_file_bytes
87
- "Error reading file: file exceeds max size (#{n} bytes): #{abs}"
88
+ "Error reading file: ollama_agent: file too large for full read (max #{n} bytes); use read_file with " \
89
+ "start_line and end_line, or raise OLLAMA_AGENT_MAX_READ_FILE_BYTES. Path: #{abs}"
88
90
  end
89
91
 
90
92
  def max_read_file_bytes
@@ -137,38 +139,60 @@ module OllamaAgent
137
139
  dir = directory.to_s.empty? ? "." : directory
138
140
  return disallowed_path_message(dir) unless path_allowed?(dir)
139
141
 
140
- search_with_ripgrep(pattern, dir) || search_with_grep(pattern, dir)
142
+ return search_code_no_backends_message unless rg_available? || grep_available?
143
+
144
+ return search_with_ripgrep(pattern, dir) if rg_available?
145
+
146
+ search_with_grep!(pattern, dir)
141
147
  end
142
148
 
143
149
  def search_with_ripgrep(pattern, directory)
144
- return nil unless rg_available?
145
-
146
150
  stdout, = Open3.capture2("rg", "-n", "--", pattern, resolve_path(directory))
147
151
  stdout.to_s
148
152
  end
149
153
 
150
- def search_with_grep(pattern, directory)
154
+ def search_with_grep!(pattern, directory)
151
155
  stdout, = Open3.capture2("grep", "-rn", "--", pattern, resolve_path(directory))
152
156
  stdout.to_s
153
157
  end
154
158
 
159
+ def search_code_no_backends_message
160
+ <<~MSG.strip
161
+ Error: ollama_agent: no text search backend available. Install ripgrep (`rg`) or GNU grep on PATH.
162
+ MSG
163
+ end
164
+
155
165
  def rg_available?
156
166
  system("which", "rg", out: File::NULL, err: File::NULL)
157
167
  end
158
168
 
169
+ def grep_available?
170
+ system("which", "grep", out: File::NULL, err: File::NULL)
171
+ end
172
+
159
173
  def edit_file(path, diff)
160
174
  return disallowed_path_message(path) unless path_allowed?(path)
175
+ return "edit_file is disabled in read-only mode." if @read_only
161
176
 
162
177
  diff = DiffPathValidator.normalize_diff(diff)
163
178
 
164
179
  validation = validate_edit_diff(path, diff)
165
180
  return validation if validation
166
181
 
167
- return "Cancelled by user" if @confirm_patches && !user_confirms_patch?(path, diff)
182
+ return "Rejected: diff matches a forbidden pattern (unsafe)." if PatchRisk.forbidden?(diff)
183
+
184
+ return "Cancelled by user" if patch_confirmation_needed?(path, diff) && !user_confirms_patch?(path, diff)
168
185
 
169
186
  apply_patch(diff)
170
187
  end
171
188
 
189
+ def patch_confirmation_needed?(path, diff)
190
+ return false unless @confirm_patches
191
+ return true unless @patch_policy
192
+
193
+ @patch_policy.call(path, diff) == :require_confirmation
194
+ end
195
+
172
196
  def validate_edit_diff(path, diff)
173
197
  mismatch = DiffPathValidator.call(diff, @root, path)
174
198
  return log_tool_message(mismatch) if mismatch
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../agent"
4
+
5
+ module OllamaAgent
6
+ module SelfImprovement
7
+ # Runs a read-only agent pass to review the gem (no edits).
8
+ class Analyzer
9
+ DEFAULT_PROMPT = <<~PROMPT
10
+ Perform a self-review of this gem: architecture, tests, clarity, and risks.
11
+ Use tools to inspect real files; finish with prioritized, actionable recommendations.
12
+ PROMPT
13
+
14
+ # Mode 2 (interactive): full tool loop on the real tree; user confirms each patch like `ask`.
15
+ INTERACTIVE_PROMPT = <<~PROMPT
16
+ You are improving the ollama_agent gem in this working tree (not a sandbox).
17
+ Use list_files, search_code, and read_file, then apply small unified diffs with edit_file.
18
+ Keep changes reviewable; prefer tests, docs, and clarity fixes.
19
+ When finished, summarize what you changed or suggested next.
20
+ PROMPT
21
+
22
+ def initialize(agent)
23
+ @agent = agent
24
+ end
25
+
26
+ def run(prompt = DEFAULT_PROMPT)
27
+ @agent.run(prompt)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "pathname"
6
+
7
+ require_relative "../agent"
8
+ require_relative "../patch_risk"
9
+
10
+ module OllamaAgent
11
+ module SelfImprovement
12
+ # Copies the project into a temp directory, runs the agent with optional semi-auto patch policy,
13
+ # runs the test suite in the sandbox, and optionally merges changed files back to the source tree.
14
+ # rubocop:disable Metrics/ClassLength -- orchestration + restore + merge helpers
15
+ class Improver
16
+ SANDBOX_EXCLUDE = %w[.git vendor coverage tmp .bundle .cursor node_modules .rspec_status].freeze
17
+
18
+ # Basenames never merged back from the sandbox (test runs create these; they are gitignored).
19
+ MERGE_SKIP_BASENAMES = %w[.rspec_status].freeze
20
+
21
+ FIX_PROMPT = <<~PROMPT
22
+ You are improving the ollama_agent Ruby gem in this temporary sandbox copy.
23
+ Use list_files, search_code, and read_file to understand the code, then edit_file with valid unified diffs.
24
+ Prefer small, reviewable changes: fixes, tests, docs, and clarity.
25
+ Minimal diffs only: fewest lines per edit_file, exact @@ counts—no whole-method or mega-hunks.
26
+ Do not delete Gemfile, Gemfile.lock, the gemspec, or exe/; the improve run restores those from the source
27
+ tree before tests, but deleting them breaks the session.
28
+ Do not add or rely on .rspec_status or other ignored test-artifact files; RSpec may create them during the test step.
29
+
30
+ When finished, summarize what you changed in plain language.
31
+ PROMPT
32
+
33
+ # rubocop:disable Metrics/ParameterLists -- mirrors CLI; keeps call sites explicit
34
+ # rubocop:disable Metrics/MethodLength
35
+ def run(model: nil, root: nil, yes: false, semi: true, apply: false, http_timeout: nil, think: nil, client: nil)
36
+ source_root = resolve_source_root(root)
37
+ sandbox_root = Dir.mktmpdir("ollama_agent_improve_")
38
+ policy = semi ? PatchRisk.method(:assess).to_proc : nil
39
+
40
+ begin
41
+ copy_project_into_sandbox(source_root, sandbox_root)
42
+ run_agent_session(
43
+ sandbox_root,
44
+ client: client,
45
+ model: model,
46
+ confirm_patches: !yes,
47
+ patch_policy: policy,
48
+ http_timeout: http_timeout,
49
+ think: think
50
+ )
51
+ restore_build_essentials_from_source(source_root, sandbox_root)
52
+ missing = missing_gemfile_failure(source_root, sandbox_root)
53
+ return build_run_result(missing, [], source_root) if missing
54
+
55
+ test_result = run_test_suite(sandbox_root)
56
+ copied = copy_back_if_requested(test_result, apply, sandbox_root, source_root)
57
+ build_run_result(test_result, copied, source_root)
58
+ ensure
59
+ FileUtils.remove_entry(sandbox_root) if sandbox_root && Dir.exist?(sandbox_root)
60
+ end
61
+ end
62
+ # rubocop:enable Metrics/MethodLength
63
+ # rubocop:enable Metrics/ParameterLists
64
+
65
+ private
66
+
67
+ def run_agent_session(sandbox_root, **)
68
+ agent = Agent.new(root: sandbox_root, **)
69
+ agent.run(FIX_PROMPT)
70
+ end
71
+
72
+ def copy_back_if_requested(test_result, apply, sandbox_root, source_root)
73
+ return [] unless test_result[:success] && apply
74
+
75
+ merge_sandbox_into_source(sandbox_root, source_root)
76
+ end
77
+
78
+ def build_run_result(test_result, copied, source_root)
79
+ {
80
+ success: test_result[:success],
81
+ test_output: test_result[:output],
82
+ copied_to_source: copied,
83
+ source_root: source_root
84
+ }
85
+ end
86
+
87
+ def copy_project_into_sandbox(source, dest)
88
+ Dir.children(source).each do |entry|
89
+ next if SANDBOX_EXCLUDE.include?(entry)
90
+
91
+ FileUtils.cp_r(File.join(source, entry), File.join(dest, entry))
92
+ end
93
+ end
94
+
95
+ def resolve_source_root(root)
96
+ start_dir = normalize_improve_root(root)
97
+ nearest = nearest_directory_with_gemfile(start_dir)
98
+ nearest || start_dir
99
+ end
100
+
101
+ def normalize_improve_root(root)
102
+ return default_improve_source_root if root.nil? || root.to_s.strip.empty?
103
+
104
+ File.expand_path(root)
105
+ end
106
+
107
+ # Installed gems omit Gemfile (see gemspec); gem_root may not contain one. Prefer cwd / env from CLI.
108
+ def default_improve_source_root
109
+ from_cwd = nearest_directory_with_gemfile(Dir.pwd)
110
+ return from_cwd if from_cwd
111
+
112
+ from_gem = nearest_directory_with_gemfile(OllamaAgent.gem_root)
113
+ return from_gem if from_gem
114
+
115
+ OllamaAgent.gem_root
116
+ end
117
+
118
+ def nearest_directory_with_gemfile(start_dir)
119
+ dir = File.expand_path(start_dir)
120
+ loop do
121
+ return dir if File.file?(File.join(dir, "Gemfile"))
122
+
123
+ parent = File.dirname(dir)
124
+ return nil if parent == dir
125
+
126
+ dir = parent
127
+ end
128
+ end
129
+
130
+ def missing_gemfile_failure(source_root, sandbox_root)
131
+ return nil if File.file?(File.join(sandbox_root, "Gemfile"))
132
+
133
+ msg = <<~MSG
134
+ Cannot run tests: Gemfile is missing in the sandbox after restore from #{source_root}.
135
+ Run `improve` from your project checkout, set OLLAMA_AGENT_ROOT, or pass --root to a tree that contains a Gemfile.
136
+ (The packaged gem does not ship a Gemfile; cwd is used when gem_root has none.)
137
+ MSG
138
+ { success: false, output: msg.strip }
139
+ end
140
+
141
+ # The model may delete or corrupt Gemfile / lock / gemspec during edit_file; bundle needs them in the sandbox.
142
+ def restore_build_essentials_from_source(source, sandbox)
143
+ %w[Gemfile Gemfile.lock Rakefile .ruby-version].each do |name|
144
+ src = File.join(source, name)
145
+ next unless File.file?(src)
146
+
147
+ FileUtils.cp(src, File.join(sandbox, name))
148
+ end
149
+
150
+ Dir.glob(File.join(source, "*.gemspec")).each do |src|
151
+ FileUtils.cp(src, File.join(sandbox, File.basename(src)))
152
+ end
153
+ end
154
+
155
+ def run_test_suite(dir)
156
+ output, status = bundle_exec(dir, "rspec", "spec/")
157
+ return { success: true, output: output } if status.success?
158
+
159
+ install_out, install_status = bundle_install(dir)
160
+ combined = "#{install_out}\n#{output}"
161
+ return { success: false, output: combined } unless install_status.success?
162
+
163
+ output2, status2 = bundle_exec(dir, "rspec", "spec/")
164
+ { success: status2.success?, output: "#{combined}\n#{output2}" }
165
+ end
166
+
167
+ def bundle_env(dir)
168
+ { "BUNDLE_GEMFILE" => File.expand_path(File.join(dir, "Gemfile")) }
169
+ end
170
+
171
+ def bundle_exec(dir, *)
172
+ Open3.capture2e(bundle_env(dir), "bundle", "exec", *, chdir: dir)
173
+ end
174
+
175
+ def bundle_install(dir)
176
+ Open3.capture2e(bundle_env(dir), "bundle", "install", chdir: dir)
177
+ end
178
+
179
+ # rubocop:disable Metrics/MethodLength -- straight-line file copy loop
180
+ def merge_sandbox_into_source(sandbox, source)
181
+ paths = []
182
+ each_relative_file(sandbox) do |rel|
183
+ next if MERGE_SKIP_BASENAMES.include?(File.basename(rel))
184
+
185
+ src = File.join(sandbox, rel)
186
+ dst = File.join(source, rel)
187
+ next unless File.file?(src)
188
+ next unless file_differs?(src, dst)
189
+
190
+ FileUtils.mkdir_p(File.dirname(dst))
191
+ FileUtils.cp(src, dst)
192
+ paths << rel
193
+ end
194
+ paths
195
+ end
196
+ # rubocop:enable Metrics/MethodLength
197
+
198
+ def each_relative_file(base)
199
+ base_path = Pathname.new(base)
200
+ Dir.glob(File.join(base, "**", "*"), File::FNM_DOTMATCH).each do |abs|
201
+ next if File.directory?(abs)
202
+ next if abs.include?("#{File::SEPARATOR}vendor#{File::SEPARATOR}")
203
+ next if abs.include?("#{File::SEPARATOR}.git#{File::SEPARATOR}")
204
+
205
+ rel = Pathname(abs).relative_path_from(base_path).to_s
206
+ yield rel
207
+ end
208
+ end
209
+
210
+ def file_differs?(sandbox_file, target_file)
211
+ return true unless File.file?(target_file)
212
+
213
+ File.binread(sandbox_file) != File.binread(target_file)
214
+ end
215
+ end
216
+ # rubocop:enable Metrics/ClassLength
217
+ end
218
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module SelfImprovement
5
+ # CLI modes for self_review / improve (analysis-only, interactive fixes, automated sandbox).
6
+ module Modes
7
+ VALID = %w[analysis interactive automated].freeze
8
+
9
+ module_function
10
+
11
+ def normalize(mode)
12
+ case mode.to_s.strip.downcase
13
+ when "", "analysis", "1", "readonly", "read-only" then "analysis"
14
+ when "interactive", "2", "fix", "confirm" then "interactive"
15
+ when "automated", "3", "sandbox", "full" then "automated"
16
+ else mode.to_s.strip.downcase
17
+ end
18
+ end
19
+
20
+ def valid?(mode)
21
+ VALID.include?(mode)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "self_improvement/modes"
4
+ require_relative "self_improvement/analyzer"
5
+ require_relative "self_improvement/improver"
@@ -12,7 +12,18 @@ module OllamaAgent
12
12
  inner = h["parameters"] || h[:parameters]
13
13
  return h unless inner.is_a?(Hash)
14
14
 
15
- inner.merge(h.except("parameters", :parameters))
15
+ outer = h.except("parameters", :parameters)
16
+ merge_parameters_with_outer(inner, outer)
17
+ end
18
+
19
+ def merge_parameters_with_outer(inner, outer)
20
+ inner.merge(outer) do |_key, inner_val, outer_val|
21
+ if inner_val.is_a?(Hash) && outer_val.is_a?(Hash)
22
+ merge_parameters_with_outer(inner_val, outer_val)
23
+ else
24
+ outer_val
25
+ end
26
+ end
16
27
  end
17
28
 
18
29
  def blank_tool_value?(value)
@@ -75,4 +75,6 @@ module OllamaAgent
75
75
  }
76
76
  }
77
77
  ].freeze
78
+
79
+ READ_ONLY_TOOLS = TOOLS.reject { |t| t.dig(:function, :name) == "edit_file" }.freeze
78
80
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OllamaAgent
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/ollama_agent.rb CHANGED
@@ -6,6 +6,13 @@ require_relative "ollama_agent/console"
6
6
  require_relative "ollama_agent/agent"
7
7
  require_relative "ollama_agent/cli"
8
8
 
9
+ # Public namespace for the Ollama-backed coding agent gem (CLI, Agent, tools, self-improvement helpers).
9
10
  module OllamaAgent
10
11
  class Error < StandardError; end
12
+
13
+ def self.gem_root
14
+ File.expand_path("..", __dir__)
15
+ end
11
16
  end
17
+
18
+ require_relative "ollama_agent/self_improvement"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ollama_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shubham Taywade
@@ -134,6 +134,7 @@ files:
134
134
  - lib/ollama_agent/console.rb
135
135
  - lib/ollama_agent/diff_path_validator.rb
136
136
  - lib/ollama_agent/ollama_connection.rb
137
+ - lib/ollama_agent/patch_risk.rb
137
138
  - lib/ollama_agent/patch_support.rb
138
139
  - lib/ollama_agent/repo_list.rb
139
140
  - lib/ollama_agent/ruby_index.rb
@@ -145,6 +146,10 @@ files:
145
146
  - lib/ollama_agent/ruby_index_tool_support.rb
146
147
  - lib/ollama_agent/ruby_search_modes.rb
147
148
  - lib/ollama_agent/sandboxed_tools.rb
149
+ - lib/ollama_agent/self_improvement.rb
150
+ - lib/ollama_agent/self_improvement/analyzer.rb
151
+ - lib/ollama_agent/self_improvement/improver.rb
152
+ - lib/ollama_agent/self_improvement/modes.rb
148
153
  - lib/ollama_agent/think_param.rb
149
154
  - lib/ollama_agent/timeout_param.rb
150
155
  - lib/ollama_agent/tool_arguments.rb