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,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "token_counter"
4
+
5
+ module OllamaAgent
6
+ module Context
7
+ # Trims the messages array to fit within a token budget before each chat call.
8
+ # Never mutates the input. Never removes the system message or the last user message.
9
+ class Manager
10
+ DEFAULT_MAX_TOKENS = 32_768
11
+ TRIM_THRESHOLD = 0.85
12
+
13
+ def initialize(max_tokens: nil, context_summarize: false)
14
+ @max_tokens = (max_tokens || env_max_tokens).to_i
15
+ @context_summarize = context_summarize
16
+ end
17
+
18
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
19
+ # Returns a (possibly shorter) copy of messages that fits within the token budget.
20
+ def trim(messages)
21
+ normalized = messages.map { |m| m.transform_keys(&:to_sym) }
22
+ estimates = normalized.map { |m| TokenCounter.estimate(m[:content].to_s) }
23
+ return normalized unless over_budget_sum?(estimates)
24
+
25
+ trimmed = normalized.dup
26
+ est = estimates.dup
27
+ last_user_idx = trimmed.rindex { |m| m[:role] == "user" }
28
+ dropped = [] if @context_summarize
29
+
30
+ while over_budget_sum?(est)
31
+ drop_idx = find_droppable_index(trimmed, last_user_idx)
32
+ break if drop_idx.nil?
33
+
34
+ msgs = if assistant_with_tool_calls?(trimmed[drop_idx])
35
+ drop_assistant_and_tool_results(trimmed, est, drop_idx)
36
+ else
37
+ est.delete_at(drop_idx)
38
+ [trimmed.delete_at(drop_idx)]
39
+ end
40
+ dropped&.concat(msgs)
41
+ last_user_idx = trimmed.rindex { |m| m[:role] == "user" }
42
+ end
43
+
44
+ inject_summary(trimmed, dropped) if @context_summarize && dropped&.any?
45
+ trimmed
46
+ end
47
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
48
+
49
+ private
50
+
51
+ def inject_summary(messages, dropped)
52
+ n = dropped.size
53
+ summary = "Note: Earlier conversation history was trimmed to fit token budget (#{n} messages dropped)."
54
+ messages.insert(1, { role: "system", content: summary })
55
+ end
56
+
57
+ def over_budget_sum?(estimates)
58
+ estimates.sum > (@max_tokens * TRIM_THRESHOLD).to_i
59
+ end
60
+
61
+ def find_droppable_index(messages, last_user_idx)
62
+ messages.each_index.find do |i|
63
+ messages[i][:role] != "system" && i != last_user_idx
64
+ end
65
+ end
66
+
67
+ def assistant_with_tool_calls?(message)
68
+ message[:role] == "assistant" && message[:tool_calls] && !message[:tool_calls].empty?
69
+ end
70
+
71
+ # rubocop:disable Metrics/MethodLength -- drop contiguous tool rows and parallel token estimates
72
+ def drop_assistant_and_tool_results(messages, estimates, assistant_idx)
73
+ indices_to_drop = [assistant_idx]
74
+ ((assistant_idx + 1)...messages.size).each do |i|
75
+ break if messages[i][:role] != "tool"
76
+
77
+ indices_to_drop << i
78
+ end
79
+
80
+ dropped_messages = indices_to_drop.map { |i| messages[i] }
81
+ indices_to_drop.sort.reverse_each do |i|
82
+ messages.delete_at(i)
83
+ estimates.delete_at(i)
84
+ end
85
+
86
+ dropped_messages
87
+ end
88
+ # rubocop:enable Metrics/MethodLength
89
+
90
+ def env_max_tokens
91
+ v = ENV.fetch("OLLAMA_AGENT_MAX_TOKENS", nil)
92
+ return DEFAULT_MAX_TOKENS if v.nil? || v.to_s.strip.empty?
93
+
94
+ Integer(v)
95
+ rescue ArgumentError, TypeError
96
+ DEFAULT_MAX_TOKENS
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module Context
5
+ # Estimates token count using chars/4 integer division (floor).
6
+ # Auto-upgrades to tiktoken_ruby (gpt-4 tokenizer) if available in host bundle.
7
+ module TokenCounter
8
+ @tokenizer = nil
9
+ @tiktoken_loaded = false
10
+
11
+ def self.estimate(text)
12
+ ensure_tiktoken_loaded
13
+ return @tokenizer.encode(text.to_s).length if @tokenizer
14
+
15
+ text.to_s.length / 4
16
+ end
17
+
18
+ def self.ensure_tiktoken_loaded
19
+ return if @tiktoken_loaded
20
+
21
+ begin
22
+ require "tiktoken_ruby"
23
+ @tokenizer = Tiktoken.encoding_for_model("gpt-4")
24
+ rescue LoadError
25
+ @tokenizer = nil
26
+ ensure
27
+ @tiktoken_loaded = true
28
+ end
29
+ end
30
+ private_class_method :ensure_tiktoken_loaded
31
+ end
32
+ end
33
+ end
@@ -4,6 +4,7 @@ require "pathname"
4
4
 
5
5
  module OllamaAgent
6
6
  # Validates unified diffs: hunk headers and path alignment with edit_file (applyability is patch --dry-run).
7
+ # rubocop:disable Metrics/ClassLength -- normalization helpers live alongside validation
7
8
  class DiffPathValidator
8
9
  # Legacy context-diff hunks (`--- N,M ----`) are not unified diffs; models sometimes emit them by mistake.
9
10
  CONTEXT_DIFF_HUNK = /^\s*---\s+\d+\s*,\s*\d+\s*----\s*$/m
@@ -14,16 +15,36 @@ module OllamaAgent
14
15
 
15
16
  # Normalizes newlines and escaped "\\n" sequences models sometimes send in tool args.
16
17
  def self.normalize_diff(diff)
17
- d = diff.to_s
18
- d = d.gsub("\r\n", "\n").gsub("\r", "\n")
19
- d = d.gsub("\\\\n", "\n").gsub("\\n", "\n") if d.include?("\\n") && !d.include?("\n")
18
+ d = normalize_newlines(diff.to_s)
19
+ d = expand_escaped_newlines_when_no_real_newlines(d)
20
20
  d = strip_cursor_patch_markers(d)
21
- # Strip trailing commas on ---/+++ lines (models copy commas from bad examples).
22
- d = d.gsub(/^((?:---|\+\+\+)[^\n]+),\s*$/m, "\\1")
23
- # Split "--- a/foo @@ -1,3" when glued on one line (common LLM mistake).
24
- d = d.gsub(/(\S)\s(@@ -\d[^\n]*)/, "\\1\n\\2")
25
- d += "\n" unless d.empty? || d.end_with?("\n")
26
- d
21
+ d = strip_trailing_commas_on_headers(d)
22
+ d = split_glued_hunk_headers(d)
23
+ ensure_trailing_newline(d)
24
+ end
25
+
26
+ def self.normalize_newlines(diff)
27
+ diff.gsub("\r\n", "\n").gsub("\r", "\n")
28
+ end
29
+
30
+ def self.expand_escaped_newlines_when_no_real_newlines(diff)
31
+ return diff unless diff.include?("\\n") && !diff.include?("\n")
32
+
33
+ diff.gsub("\\\\n", "\n").gsub("\\n", "\n")
34
+ end
35
+
36
+ def self.strip_trailing_commas_on_headers(diff)
37
+ diff.gsub(/^((?:---|\+\+\+)[^\n]+),\s*$/m, "\\1")
38
+ end
39
+
40
+ def self.split_glued_hunk_headers(diff)
41
+ diff.gsub(/(\S)\s(@@ -\d[^\n]*)/, "\\1\n\\2")
42
+ end
43
+
44
+ def self.ensure_trailing_newline(diff)
45
+ return diff if diff.empty? || diff.end_with?("\n")
46
+
47
+ "#{diff}\n"
27
48
  end
28
49
 
29
50
  def self.strip_cursor_patch_markers(diff)
@@ -77,7 +98,7 @@ module OllamaAgent
77
98
 
78
99
  <<~MSG.strip
79
100
  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`.
101
+ Put a +++ b/<file> line before the first @@ hunk (e.g. +++ b/README.md); do not place @@ immediately after --- alone.
81
102
  MSG
82
103
  end
83
104
 
@@ -138,4 +159,5 @@ module OllamaAgent
138
159
  Pathname(path).cleanpath.to_s
139
160
  end
140
161
  end
162
+ # rubocop:enable Metrics/ClassLength
141
163
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ # Centralized ENV parsing with safe fallbacks; warns on malformed values when OLLAMA_AGENT_DEBUG=1.
5
+ # Set OLLAMA_AGENT_STRICT_ENV=1 to raise {ConfigurationError} on invalid numeric values (CI / operators).
6
+ module EnvConfig
7
+ module_function
8
+
9
+ # @return [Boolean] true when +OLLAMA_AGENT_STRICT_ENV=1+ (invalid numeric ENV raises {ConfigurationError}).
10
+ def strict_env?
11
+ ENV["OLLAMA_AGENT_STRICT_ENV"] == "1"
12
+ end
13
+
14
+ def warn_invalid(name, raw, fallback)
15
+ return unless ENV["OLLAMA_AGENT_DEBUG"] == "1"
16
+
17
+ warn "ollama_agent: #{name}=#{raw.inspect} is invalid; using #{fallback}."
18
+ end
19
+
20
+ def fetch_int(name, default, strict: strict_env?)
21
+ v = ENV.fetch(name, nil)
22
+ return default if v.nil? || v.to_s.strip.empty?
23
+
24
+ Integer(v)
25
+ rescue ArgumentError, TypeError
26
+ raise ConfigurationError, "ollama_agent: #{name}=#{v.inspect} is not a valid integer" if strict
27
+
28
+ warn_invalid(name, v, default)
29
+ default
30
+ end
31
+
32
+ def fetch_float(name, default, strict: strict_env?)
33
+ v = ENV.fetch(name, nil)
34
+ return default if v.nil? || v.to_s.strip.empty?
35
+
36
+ Float(v)
37
+ rescue ArgumentError, TypeError
38
+ raise ConfigurationError, "ollama_agent: #{name}=#{v.inspect} is not a valid float" if strict
39
+
40
+ warn_invalid(name, v, default)
41
+ default
42
+ end
43
+ end
44
+ end