ollama_agent 0.1.0 → 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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/skills/ruby-code-review-levels/SKILL.md +115 -0
  3. data/.cursor/skills/self-improvement-sandbox-safety/SKILL.md +65 -0
  4. data/.env.example +25 -0
  5. data/CHANGELOG.md +40 -0
  6. data/README.md +135 -4
  7. data/docs/ARCHITECTURE.md +42 -0
  8. data/docs/PERFORMANCE.md +22 -0
  9. data/docs/SESSIONS.md +48 -0
  10. data/docs/TOOLS.md +53 -0
  11. data/docs/TOOL_RUNTIME.md +154 -0
  12. data/docs/superpowers/plans/2026-03-26-production-ready-ollama-agent.md +2454 -0
  13. data/docs/superpowers/specs/2026-03-26-production-ready-ollama-agent-design.md +400 -0
  14. data/lib/ollama_agent/agent/agent_config.rb +53 -0
  15. data/lib/ollama_agent/agent/client_wiring.rb +76 -0
  16. data/lib/ollama_agent/agent/prompt_wiring.rb +55 -0
  17. data/lib/ollama_agent/agent/session_wiring.rb +53 -0
  18. data/lib/ollama_agent/agent.rb +148 -73
  19. data/lib/ollama_agent/agent_prompt.rb +31 -1
  20. data/lib/ollama_agent/chat_stream_carry.rb +88 -0
  21. data/lib/ollama_agent/chat_stream_thinking_format.rb +29 -0
  22. data/lib/ollama_agent/cli.rb +394 -4
  23. data/lib/ollama_agent/console.rb +177 -5
  24. data/lib/ollama_agent/context/manager.rb +100 -0
  25. data/lib/ollama_agent/context/token_counter.rb +33 -0
  26. data/lib/ollama_agent/diff_path_validator.rb +32 -10
  27. data/lib/ollama_agent/env_config.rb +44 -0
  28. data/lib/ollama_agent/external_agents/TODO-plan.md +1948 -0
  29. data/lib/ollama_agent/external_agents/argv_interp.rb +21 -0
  30. data/lib/ollama_agent/external_agents/default_agents.yml +60 -0
  31. data/lib/ollama_agent/external_agents/delegate_logger.rb +31 -0
  32. data/lib/ollama_agent/external_agents/delegate_timeout_status.rb +12 -0
  33. data/lib/ollama_agent/external_agents/env_helpers.rb +38 -0
  34. data/lib/ollama_agent/external_agents/path_validator.rb +32 -0
  35. data/lib/ollama_agent/external_agents/probe.rb +122 -0
  36. data/lib/ollama_agent/external_agents/registry.rb +50 -0
  37. data/lib/ollama_agent/external_agents/runner.rb +118 -0
  38. data/lib/ollama_agent/external_agents.rb +9 -0
  39. data/lib/ollama_agent/global_dotenv.rb +39 -0
  40. data/lib/ollama_agent/model_env.rb +26 -0
  41. data/lib/ollama_agent/ollama_chat_thinking_stream.rb +41 -0
  42. data/lib/ollama_agent/ollama_connection.rb +6 -1
  43. data/lib/ollama_agent/patch_risk.rb +81 -0
  44. data/lib/ollama_agent/patch_support.rb +27 -1
  45. data/lib/ollama_agent/path_sandbox.rb +62 -0
  46. data/lib/ollama_agent/prompt_skills/clean_ruby.md +131 -0
  47. data/lib/ollama_agent/prompt_skills/code_review.md +112 -0
  48. data/lib/ollama_agent/prompt_skills/design_patterns.md +56 -0
  49. data/lib/ollama_agent/prompt_skills/manifest.yml +25 -0
  50. data/lib/ollama_agent/prompt_skills/ollama_agent_patterns.md +132 -0
  51. data/lib/ollama_agent/prompt_skills/rails_best_practices.md +41 -0
  52. data/lib/ollama_agent/prompt_skills/rails_style.md +138 -0
  53. data/lib/ollama_agent/prompt_skills/rspec.md +280 -0
  54. data/lib/ollama_agent/prompt_skills/rubocop.md +7 -0
  55. data/lib/ollama_agent/prompt_skills/ruby_style.md +121 -0
  56. data/lib/ollama_agent/prompt_skills/solid.md +270 -0
  57. data/lib/ollama_agent/prompt_skills/solid_ruby.md +223 -0
  58. data/lib/ollama_agent/prompt_skills.rb +169 -0
  59. data/lib/ollama_agent/repo_list.rb +4 -1
  60. data/lib/ollama_agent/resilience/audit_logger.rb +79 -0
  61. data/lib/ollama_agent/resilience/retry_middleware.rb +45 -0
  62. data/lib/ollama_agent/resilience/retry_policy.rb +51 -0
  63. data/lib/ollama_agent/ruby_index_tool_support.rb +17 -6
  64. data/lib/ollama_agent/runner.rb +123 -0
  65. data/lib/ollama_agent/sandboxed_tools/delegate_external.rb +62 -0
  66. data/lib/ollama_agent/sandboxed_tools/file_read_write.rb +100 -0
  67. data/lib/ollama_agent/sandboxed_tools/search_text.rb +60 -0
  68. data/lib/ollama_agent/sandboxed_tools.rb +55 -116
  69. data/lib/ollama_agent/search_backend.rb +93 -0
  70. data/lib/ollama_agent/self_improvement/analyzer.rb +34 -0
  71. data/lib/ollama_agent/self_improvement/improver.rb +340 -0
  72. data/lib/ollama_agent/self_improvement/modes.rb +25 -0
  73. data/lib/ollama_agent/self_improvement/ruby_mastery_context.rb +66 -0
  74. data/lib/ollama_agent/self_improvement.rb +5 -0
  75. data/lib/ollama_agent/session/session.rb +8 -0
  76. data/lib/ollama_agent/session/store.rb +68 -0
  77. data/lib/ollama_agent/streaming/console_streamer.rb +29 -0
  78. data/lib/ollama_agent/streaming/hooks.rb +39 -0
  79. data/lib/ollama_agent/tool_arguments.rb +13 -1
  80. data/lib/ollama_agent/tool_content_parser.rb +1 -1
  81. data/lib/ollama_agent/tool_runtime/executor.rb +34 -0
  82. data/lib/ollama_agent/tool_runtime/json_extractor.rb +62 -0
  83. data/lib/ollama_agent/tool_runtime/loop.rb +72 -0
  84. data/lib/ollama_agent/tool_runtime/memory.rb +32 -0
  85. data/lib/ollama_agent/tool_runtime/ollama_json_planner.rb +98 -0
  86. data/lib/ollama_agent/tool_runtime/plan_extractor.rb +12 -0
  87. data/lib/ollama_agent/tool_runtime/registry.rb +60 -0
  88. data/lib/ollama_agent/tool_runtime/tool.rb +24 -0
  89. data/lib/ollama_agent/tool_runtime.rb +24 -0
  90. data/lib/ollama_agent/tools/registry.rb +55 -0
  91. data/lib/ollama_agent/tools_schema.rb +74 -1
  92. data/lib/ollama_agent/user_prompt.rb +35 -0
  93. data/lib/ollama_agent/version.rb +1 -1
  94. data/lib/ollama_agent.rb +25 -0
  95. data/reproduce_429.rb +40 -0
  96. data/sig/ollama_agent.rbs +111 -1
  97. metadata +78 -2
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module OllamaAgent
7
+ module Resilience
8
+ # Subscribes to Streaming::Hooks and writes structured NDJSON audit logs.
9
+ # Activated by OLLAMA_AGENT_AUDIT=1 or audit: true in Runner.build.
10
+ class AuditLogger
11
+ DEFAULT_MAX_RESULT_BYTES = 4_096
12
+
13
+ def initialize(log_dir:, hooks:, max_result_bytes: nil)
14
+ @log_dir = log_dir
15
+ @hooks = hooks
16
+ @max_result_bytes = max_result_bytes || env_max_result_bytes
17
+ end
18
+
19
+ def attach
20
+ @hooks.on(:on_tool_call) { |payload| write_entry(tool_call_entry(payload)) }
21
+ @hooks.on(:on_tool_result) { |payload| write_entry(tool_result_entry(payload)) }
22
+ @hooks.on(:on_complete) { |payload| write_entry(complete_entry(payload)) }
23
+ @hooks.on(:on_error) { |payload| write_entry(error_entry(payload)) }
24
+ @hooks.on(:on_retry) { |payload| write_entry(retry_entry(payload)) }
25
+ end
26
+
27
+ private
28
+
29
+ def write_entry(hash)
30
+ FileUtils.mkdir_p(@log_dir)
31
+ File.open(log_path, "a", encoding: Encoding::UTF_8) { |f| f.puts(JSON.generate(hash)) }
32
+ rescue StandardError
33
+ nil
34
+ end
35
+
36
+ def log_path
37
+ File.join(@log_dir, "#{Time.now.strftime("%Y-%m-%d")}.ndjson")
38
+ end
39
+
40
+ def ts
41
+ Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
42
+ end
43
+
44
+ def tool_call_entry(payload)
45
+ { ts: ts, event: "tool_call", name: payload[:name], args: payload[:args], turn: payload[:turn] }
46
+ end
47
+
48
+ def tool_result_entry(payload)
49
+ result = payload[:result].to_s
50
+ result = result.byteslice(0, @max_result_bytes) if result.bytesize > @max_result_bytes
51
+ { ts: ts, event: "tool_result", name: payload[:name], bytes: payload[:result].to_s.bytesize,
52
+ result_preview: result, turn: payload[:turn] }
53
+ end
54
+
55
+ def complete_entry(payload)
56
+ { ts: ts, event: "agent_complete", turns: payload[:turns] }
57
+ end
58
+
59
+ def error_entry(payload)
60
+ { ts: ts, event: "agent_error", error: payload[:error].class.name,
61
+ message: payload[:error].message, turn: payload[:turn] }
62
+ end
63
+
64
+ def retry_entry(payload)
65
+ { ts: ts, event: "http_retry", attempt: payload[:attempt],
66
+ delay_ms: payload[:delay_ms], error: payload[:error].class.name }
67
+ end
68
+
69
+ def env_max_result_bytes
70
+ v = ENV.fetch("OLLAMA_AGENT_AUDIT_MAX_RESULT_BYTES", nil)
71
+ return DEFAULT_MAX_RESULT_BYTES if v.nil? || v.to_s.strip.empty?
72
+
73
+ Integer(v)
74
+ rescue ArgumentError, TypeError
75
+ DEFAULT_MAX_RESULT_BYTES
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require_relative "retry_policy"
5
+
6
+ module OllamaAgent
7
+ module Resilience
8
+ # Wraps an Ollama::Client with exponential backoff retry for transient errors.
9
+ class RetryMiddleware
10
+ DEFAULT_MAX_ATTEMPTS = RetryPolicy::DEFAULT_MAX_ATTEMPTS
11
+ DEFAULT_BASE_DELAY = RetryPolicy::DEFAULT_BASE_DELAY
12
+
13
+ def initialize(client:, max_attempts: DEFAULT_MAX_ATTEMPTS, hooks: nil, base_delay: DEFAULT_BASE_DELAY)
14
+ @client = client
15
+ @hooks = hooks
16
+ @policy = RetryPolicy.new(max_attempts: max_attempts, base_delay: base_delay)
17
+ end
18
+
19
+ # rubocop:disable Metrics/MethodLength -- single rescue/retry loop
20
+ def chat(**args)
21
+ attempt = 0
22
+ begin
23
+ @client.chat(**args)
24
+ rescue *RetryPolicy::RETRYABLE => e
25
+ raise if http_error_non_retryable?(e)
26
+
27
+ attempt += 1
28
+ raise if attempt >= @policy.max_attempts
29
+
30
+ delay = @policy.backoff(attempt)
31
+ @hooks&.emit(:on_retry, { error: e, attempt: attempt, delay_ms: (delay * 1000).round })
32
+ sleep delay
33
+ retry
34
+ end
35
+ end
36
+ # rubocop:enable Metrics/MethodLength
37
+
38
+ private
39
+
40
+ def http_error_non_retryable?(error)
41
+ error.is_a?(Ollama::HTTPError) && !@policy.retryable_http_error?(error)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module OllamaAgent
6
+ module Resilience
7
+ # Retryable exception list and HTTP status / backoff rules for {RetryMiddleware}.
8
+ class RetryPolicy
9
+ DEFAULT_MAX_ATTEMPTS = 3
10
+ DEFAULT_BASE_DELAY = 2.0
11
+
12
+ RETRYABLE = begin
13
+ list = [Ollama::TimeoutError, Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET]
14
+ list << SocketError if defined?(SocketError)
15
+ list << Socket::ResolutionError if defined?(Socket::ResolutionError)
16
+ list << Ollama::Error if defined?(Ollama::Error)
17
+ list << Ollama::HTTPError if defined?(Ollama::HTTPError)
18
+ list
19
+ rescue NameError
20
+ [Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET]
21
+ end.freeze
22
+
23
+ attr_reader :max_attempts, :base_delay
24
+
25
+ def initialize(max_attempts: DEFAULT_MAX_ATTEMPTS, base_delay: DEFAULT_BASE_DELAY)
26
+ @max_attempts = max_attempts.to_i
27
+ @base_delay = base_delay.to_f
28
+ end
29
+
30
+ def retryable_http_error?(error)
31
+ msg = error.message.to_s
32
+ status = msg[/HTTP (\d+)/, 1].to_i
33
+ return false if status.zero?
34
+
35
+ if status == 429
36
+ return false if msg.downcase.include?("weekly usage limit")
37
+ return false if msg.downcase.include?("monthly usage limit")
38
+
39
+ return true
40
+ end
41
+
42
+ status.between?(500, 599)
43
+ end
44
+
45
+ def backoff(attempt)
46
+ jitter = @base_delay.positive? ? rand * 0.5 : 0
47
+ [(@base_delay * (2**(attempt - 1))) + jitter, 30.0].min
48
+ end
49
+ end
50
+ end
51
+ end
@@ -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
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "agent"
4
+ require_relative "streaming/hooks"
5
+ require_relative "streaming/console_streamer"
6
+
7
+ module OllamaAgent
8
+ # Stable public facade for library consumers.
9
+ # Configure via Runner.build, then call #run.
10
+ #
11
+ # @example
12
+ # runner = OllamaAgent::Runner.build(root: "/my/project", stream: true, audit: true)
13
+ # runner.hooks.on(:on_token) { |p| print p[:token] }
14
+ # runner.run("Refactor the auth module")
15
+ class Runner
16
+ # @return [Streaming::Hooks] the hooks bus — attach subscribers before calling #run
17
+ def hooks
18
+ @agent.hooks
19
+ end
20
+
21
+ # @return [String, nil] the current session id
22
+ attr_reader :session_id
23
+
24
+ # Build a configured Runner.
25
+ #
26
+ # @param root [String] project root directory (default: Dir.pwd)
27
+ # @param model [String, nil] Ollama model name
28
+ # @param stream [Boolean] enable streaming token output to stdout
29
+ # @param session_id [String, nil] named session for persistence
30
+ # @param resume [Boolean] load prior session messages before running
31
+ # @param max_retries [Integer] HTTP retry attempts (0 = disable)
32
+ # @param audit [Boolean] enable structured audit logging
33
+ # @param read_only [Boolean] disable write tools
34
+ # @param skills_enabled [Boolean] include bundled prompt skills
35
+ # @param skill_paths [Array<String>, nil] extra .md skill paths
36
+ # @param confirm_patches [Boolean] prompt before applying patches
37
+ # @param orchestrator [Boolean] enable external agent delegation
38
+ # @param think [String, nil] thinking mode (true/false/high/medium/low)
39
+ # @param http_timeout [Integer, nil] HTTP timeout in seconds
40
+ # @param stdin [IO] input for patch/write/delegate confirmations (default +$stdin+)
41
+ # @param stdout [IO] output for confirmation prompts (default +$stdout+)
42
+ # @return [Runner]
43
+ # rubocop:disable Metrics/ParameterLists -- library facade must expose all Agent options
44
+ def self.build(
45
+ root: Dir.pwd,
46
+ model: nil,
47
+ stream: false,
48
+ session_id: nil,
49
+ resume: false,
50
+ max_tokens: nil,
51
+ context_summarize: false,
52
+ max_retries: nil,
53
+ audit: nil,
54
+ read_only: false,
55
+ skills_enabled: true,
56
+ skill_paths: nil,
57
+ confirm_patches: true,
58
+ orchestrator: false,
59
+ think: nil,
60
+ http_timeout: nil,
61
+ stdin: $stdin,
62
+ stdout: $stdout
63
+ )
64
+ new(
65
+ root: root, model: model, stream: stream,
66
+ session_id: session_id, resume: resume,
67
+ max_tokens: max_tokens, context_summarize: context_summarize,
68
+ max_retries: max_retries, audit: audit, read_only: read_only,
69
+ skills_enabled: skills_enabled, skill_paths: skill_paths,
70
+ confirm_patches: confirm_patches, orchestrator: orchestrator,
71
+ think: think, http_timeout: http_timeout,
72
+ stdin: stdin, stdout: stdout
73
+ )
74
+ end
75
+ # rubocop:enable Metrics/ParameterLists
76
+
77
+ # Execute a query. Blocks until the agent loop completes.
78
+ # @param query [String]
79
+ def run(query)
80
+ agent.run(query)
81
+ end
82
+
83
+ protected
84
+
85
+ # Exposed for spec stubbing only.
86
+ attr_reader :agent
87
+
88
+ private
89
+
90
+ # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
91
+ def initialize(root:, model:, stream:, session_id:, resume:,
92
+ max_tokens:, context_summarize:,
93
+ max_retries:, audit:, read_only:, skills_enabled:, skill_paths:,
94
+ confirm_patches:, orchestrator:, think:, http_timeout:,
95
+ stdin:, stdout:)
96
+ @session_id = session_id
97
+
98
+ config = Agent::AgentConfig.new(
99
+ root: root,
100
+ model: model,
101
+ confirm_patches: confirm_patches,
102
+ http_timeout: http_timeout,
103
+ think: think,
104
+ read_only: read_only,
105
+ skills_enabled: skills_enabled,
106
+ skill_paths: skill_paths ? Array(skill_paths) : nil,
107
+ orchestrator: orchestrator,
108
+ session_id: session_id,
109
+ resume: resume,
110
+ max_retries: max_retries,
111
+ audit: audit,
112
+ max_tokens: max_tokens,
113
+ context_summarize: context_summarize,
114
+ stdin: stdin,
115
+ stdout: stdout
116
+ )
117
+ @agent = Agent.new(config: config)
118
+
119
+ Streaming::ConsoleStreamer.new.attach(@agent.hooks) if stream
120
+ end
121
+ # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
122
+ end
123
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module SandboxedTools
5
+ # Orchestrator tools: list and delegate to external CLI agents.
6
+ module DelegateExternal
7
+ private
8
+
9
+ def execute_list_external_agents(_args)
10
+ return "list_external_agents is only available in orchestrator mode." unless @orchestrator
11
+
12
+ require "json"
13
+ rows = external_registry.agents.map { |a| ExternalAgents::Probe.fetch_status(a) }
14
+ JSON.pretty_generate(rows)
15
+ end
16
+
17
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
18
+ def execute_delegate_to_agent_tool(args)
19
+ return "delegate_to_agent is only available in orchestrator mode." unless @orchestrator
20
+ return "delegate_to_agent is disabled in read-only mode." if @read_only
21
+
22
+ id = tool_arg(args, "agent_id")
23
+ task = tool_arg(args, "task")
24
+ return missing_tool_argument("delegate_to_agent", "agent_id") if blank_tool_value?(id)
25
+ return missing_tool_argument("delegate_to_agent", "task") if blank_tool_value?(task)
26
+
27
+ agent_def = external_registry.find(id.to_s)
28
+ return "Unknown agent_id: #{id}. Call list_external_agents first." unless agent_def
29
+
30
+ exe = ExternalAgents::Probe.resolve_executable(agent_def)
31
+ return "Agent #{id} is not available (not on PATH). Check list_external_agents." unless exe
32
+
33
+ return "Cancelled by user" if @confirm_delegation && !user_confirms_delegate?(id, task)
34
+
35
+ timeout_sec = integer_or(tool_arg(args, "timeout_seconds"), agent_def["timeout_sec"] || 600)
36
+ paths = tool_arg(args, "paths")
37
+ paths = [] unless paths.is_a?(Array)
38
+
39
+ ExternalAgents::Runner.run(
40
+ agent_def: agent_def,
41
+ root: @root,
42
+ executable: exe,
43
+ task: task,
44
+ context_summary: tool_arg(args, "context_summary").to_s,
45
+ paths: paths,
46
+ timeout_sec: timeout_sec
47
+ )
48
+ rescue ArgumentError => e
49
+ e.message
50
+ end
51
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
52
+
53
+ def external_registry
54
+ @external_registry ||= ExternalAgents::Registry.load
55
+ end
56
+
57
+ def user_confirms_delegate?(agent_id, task)
58
+ user_prompt.confirm_delegate(agent_id, task)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module SandboxedTools
5
+ # read_file / write_file and line-range helpers.
6
+ module FileReadWrite
7
+ private
8
+
9
+ def execute_read_file(args)
10
+ path = tool_arg(args, "path")
11
+ return missing_tool_argument("read_file", "path") if blank_tool_value?(path)
12
+
13
+ read_file(
14
+ path,
15
+ start_line: tool_arg(args, "start_line"),
16
+ end_line: tool_arg(args, "end_line")
17
+ )
18
+ end
19
+
20
+ def execute_write_file_tool(args)
21
+ path = tool_arg(args, "path")
22
+ content = tool_arg(args, "content")
23
+ return missing_tool_argument("write_file", "path") if blank_tool_value?(path)
24
+ return missing_tool_argument("write_file", "content") if content.nil?
25
+
26
+ write_file(path, content)
27
+ end
28
+
29
+ def write_file(path, content)
30
+ return disallowed_path_message(path) unless path_allowed?(path)
31
+ return "write_file is disabled in read-only mode." if @read_only
32
+
33
+ return "Cancelled by user" if @confirm_patches && !user_prompt.confirm_write_file(path, content.to_s[0, 2000])
34
+
35
+ abs = resolve_path(path)
36
+ FileUtils.mkdir_p(File.dirname(abs))
37
+ File.write(abs, content.to_s, encoding: Encoding::UTF_8)
38
+ "Written: #{path}"
39
+ rescue Errno::EACCES => e
40
+ "Error writing file: #{e.message}"
41
+ end
42
+
43
+ def read_file(path, start_line: nil, end_line: nil)
44
+ return disallowed_path_message(path) unless path_allowed?(path)
45
+
46
+ abs = resolve_path(path)
47
+ return read_file_lines(abs, start_line, end_line) if start_line || end_line
48
+
49
+ return read_file_too_large(abs) if File.size(abs) > max_read_file_bytes
50
+
51
+ File.read(abs)
52
+ rescue Errno::ENOENT => e
53
+ "Error reading file: #{e.message}"
54
+ end
55
+
56
+ def read_file_too_large(abs)
57
+ n = max_read_file_bytes
58
+ "Error reading file: ollama_agent: file too large for full read (max #{n} bytes); use read_file with " \
59
+ "start_line and end_line, or raise OLLAMA_AGENT_MAX_READ_FILE_BYTES. Path: #{abs}"
60
+ end
61
+
62
+ def max_read_file_bytes
63
+ EnvConfig.fetch_int(
64
+ "OLLAMA_AGENT_MAX_READ_FILE_BYTES",
65
+ DEFAULT_MAX_READ_FILE_BYTES,
66
+ strict: EnvConfig.strict_env?
67
+ )
68
+ end
69
+
70
+ def read_file_lines(abs, start_line, end_line)
71
+ start_i = read_line_start_index(start_line)
72
+ end_i = read_line_end_index(end_line)
73
+ return "" if end_i && start_i > end_i
74
+
75
+ accumulate_file_lines(abs, start_i, end_i)
76
+ rescue Errno::ENOENT => e
77
+ "Error reading file: #{e.message}"
78
+ end
79
+
80
+ def read_line_start_index(start_line)
81
+ [integer_or(start_line, 1), 1].max
82
+ end
83
+
84
+ def read_line_end_index(end_line)
85
+ end_line.nil? ? nil : integer_or(end_line, 1)
86
+ end
87
+
88
+ def accumulate_file_lines(abs, start_i, end_i)
89
+ buf = +""
90
+ File.foreach(abs).with_index(1) do |line, lineno|
91
+ next if lineno < start_i
92
+ break if end_i && lineno > end_i
93
+
94
+ buf << line
95
+ end
96
+ buf
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../search_backend"
4
+
5
+ module OllamaAgent
6
+ module SandboxedTools
7
+ # ripgrep/grep-backed search_code (text mode).
8
+ module SearchText
9
+ private
10
+
11
+ def execute_search_code(args)
12
+ pattern = tool_arg(args, "pattern").to_s
13
+ mode = (tool_arg(args, "mode") || "text").to_s.downcase
14
+
15
+ return missing_tool_argument("search_code", "pattern") if blank_tool_value?(pattern)
16
+
17
+ return search_code_ruby(pattern, mode) if ruby_search_mode?(mode)
18
+
19
+ search_code(pattern, tool_arg(args, "directory") || ".")
20
+ end
21
+
22
+ def search_code(pattern, directory)
23
+ dir = directory.to_s.empty? ? "." : directory
24
+ return disallowed_path_message(dir) unless path_allowed?(dir)
25
+
26
+ return search_code_no_backends_message unless rg_available? || grep_available?
27
+
28
+ return search_with_ripgrep(pattern, dir) if rg_available?
29
+
30
+ search_with_grep!(pattern, dir)
31
+ end
32
+
33
+ def search_with_ripgrep(pattern, directory)
34
+ bin = SearchBackend.rg_executable
35
+ stdout, = Open3.capture2(bin, "-n", "--", pattern, resolve_path(directory))
36
+ stdout.to_s
37
+ end
38
+
39
+ def search_with_grep!(pattern, directory)
40
+ bin = SearchBackend.grep_executable
41
+ stdout, = Open3.capture2(bin, "-rn", "--", pattern, resolve_path(directory))
42
+ stdout.to_s
43
+ end
44
+
45
+ def search_code_no_backends_message
46
+ <<~MSG.strip
47
+ Error: ollama_agent: no text search backend available. Install ripgrep (`rg`) or GNU grep on PATH.
48
+ MSG
49
+ end
50
+
51
+ def rg_available?
52
+ !SearchBackend.rg_executable.nil?
53
+ end
54
+
55
+ def grep_available?
56
+ !SearchBackend.grep_executable.nil?
57
+ end
58
+ end
59
+ end
60
+ end