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.
- checksums.yaml +4 -4
- data/.cursor/skills/ruby-code-review-levels/SKILL.md +115 -0
- data/.cursor/skills/self-improvement-sandbox-safety/SKILL.md +65 -0
- data/.env.example +25 -0
- data/CHANGELOG.md +40 -0
- data/README.md +135 -4
- data/docs/ARCHITECTURE.md +42 -0
- data/docs/PERFORMANCE.md +22 -0
- data/docs/SESSIONS.md +48 -0
- data/docs/TOOLS.md +53 -0
- data/docs/TOOL_RUNTIME.md +154 -0
- data/docs/superpowers/plans/2026-03-26-production-ready-ollama-agent.md +2454 -0
- data/docs/superpowers/specs/2026-03-26-production-ready-ollama-agent-design.md +400 -0
- data/lib/ollama_agent/agent/agent_config.rb +53 -0
- data/lib/ollama_agent/agent/client_wiring.rb +76 -0
- data/lib/ollama_agent/agent/prompt_wiring.rb +55 -0
- data/lib/ollama_agent/agent/session_wiring.rb +53 -0
- data/lib/ollama_agent/agent.rb +148 -73
- data/lib/ollama_agent/agent_prompt.rb +31 -1
- data/lib/ollama_agent/chat_stream_carry.rb +88 -0
- data/lib/ollama_agent/chat_stream_thinking_format.rb +29 -0
- data/lib/ollama_agent/cli.rb +394 -4
- data/lib/ollama_agent/console.rb +177 -5
- data/lib/ollama_agent/context/manager.rb +100 -0
- data/lib/ollama_agent/context/token_counter.rb +33 -0
- data/lib/ollama_agent/diff_path_validator.rb +32 -10
- data/lib/ollama_agent/env_config.rb +44 -0
- data/lib/ollama_agent/external_agents/TODO-plan.md +1948 -0
- data/lib/ollama_agent/external_agents/argv_interp.rb +21 -0
- data/lib/ollama_agent/external_agents/default_agents.yml +60 -0
- data/lib/ollama_agent/external_agents/delegate_logger.rb +31 -0
- data/lib/ollama_agent/external_agents/delegate_timeout_status.rb +12 -0
- data/lib/ollama_agent/external_agents/env_helpers.rb +38 -0
- data/lib/ollama_agent/external_agents/path_validator.rb +32 -0
- data/lib/ollama_agent/external_agents/probe.rb +122 -0
- data/lib/ollama_agent/external_agents/registry.rb +50 -0
- data/lib/ollama_agent/external_agents/runner.rb +118 -0
- data/lib/ollama_agent/external_agents.rb +9 -0
- data/lib/ollama_agent/global_dotenv.rb +39 -0
- data/lib/ollama_agent/model_env.rb +26 -0
- data/lib/ollama_agent/ollama_chat_thinking_stream.rb +41 -0
- data/lib/ollama_agent/ollama_connection.rb +6 -1
- data/lib/ollama_agent/patch_risk.rb +81 -0
- data/lib/ollama_agent/patch_support.rb +27 -1
- data/lib/ollama_agent/path_sandbox.rb +62 -0
- data/lib/ollama_agent/prompt_skills/clean_ruby.md +131 -0
- data/lib/ollama_agent/prompt_skills/code_review.md +112 -0
- data/lib/ollama_agent/prompt_skills/design_patterns.md +56 -0
- data/lib/ollama_agent/prompt_skills/manifest.yml +25 -0
- data/lib/ollama_agent/prompt_skills/ollama_agent_patterns.md +132 -0
- data/lib/ollama_agent/prompt_skills/rails_best_practices.md +41 -0
- data/lib/ollama_agent/prompt_skills/rails_style.md +138 -0
- data/lib/ollama_agent/prompt_skills/rspec.md +280 -0
- data/lib/ollama_agent/prompt_skills/rubocop.md +7 -0
- data/lib/ollama_agent/prompt_skills/ruby_style.md +121 -0
- data/lib/ollama_agent/prompt_skills/solid.md +270 -0
- data/lib/ollama_agent/prompt_skills/solid_ruby.md +223 -0
- data/lib/ollama_agent/prompt_skills.rb +169 -0
- data/lib/ollama_agent/repo_list.rb +4 -1
- data/lib/ollama_agent/resilience/audit_logger.rb +79 -0
- data/lib/ollama_agent/resilience/retry_middleware.rb +45 -0
- data/lib/ollama_agent/resilience/retry_policy.rb +51 -0
- data/lib/ollama_agent/ruby_index_tool_support.rb +17 -6
- data/lib/ollama_agent/runner.rb +123 -0
- data/lib/ollama_agent/sandboxed_tools/delegate_external.rb +62 -0
- data/lib/ollama_agent/sandboxed_tools/file_read_write.rb +100 -0
- data/lib/ollama_agent/sandboxed_tools/search_text.rb +60 -0
- data/lib/ollama_agent/sandboxed_tools.rb +55 -116
- data/lib/ollama_agent/search_backend.rb +93 -0
- data/lib/ollama_agent/self_improvement/analyzer.rb +34 -0
- data/lib/ollama_agent/self_improvement/improver.rb +340 -0
- data/lib/ollama_agent/self_improvement/modes.rb +25 -0
- data/lib/ollama_agent/self_improvement/ruby_mastery_context.rb +66 -0
- data/lib/ollama_agent/self_improvement.rb +5 -0
- data/lib/ollama_agent/session/session.rb +8 -0
- data/lib/ollama_agent/session/store.rb +68 -0
- data/lib/ollama_agent/streaming/console_streamer.rb +29 -0
- data/lib/ollama_agent/streaming/hooks.rb +39 -0
- data/lib/ollama_agent/tool_arguments.rb +13 -1
- data/lib/ollama_agent/tool_content_parser.rb +1 -1
- data/lib/ollama_agent/tool_runtime/executor.rb +34 -0
- data/lib/ollama_agent/tool_runtime/json_extractor.rb +62 -0
- data/lib/ollama_agent/tool_runtime/loop.rb +72 -0
- data/lib/ollama_agent/tool_runtime/memory.rb +32 -0
- data/lib/ollama_agent/tool_runtime/ollama_json_planner.rb +98 -0
- data/lib/ollama_agent/tool_runtime/plan_extractor.rb +12 -0
- data/lib/ollama_agent/tool_runtime/registry.rb +60 -0
- data/lib/ollama_agent/tool_runtime/tool.rb +24 -0
- data/lib/ollama_agent/tool_runtime.rb +24 -0
- data/lib/ollama_agent/tools/registry.rb +55 -0
- data/lib/ollama_agent/tools_schema.rb +74 -1
- data/lib/ollama_agent/user_prompt.rb +35 -0
- data/lib/ollama_agent/version.rb +1 -1
- data/lib/ollama_agent.rb +25 -0
- data/reproduce_429.rb +40 -0
- data/sig/ollama_agent.rbs +111 -1
- 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
|
|
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
|
-
|
|
22
|
-
d = d
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|