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,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module ExternalAgents
5
+ # Expands %{name} placeholders in argv templates (no shell).
6
+ module ArgvInterp
7
+ class << self
8
+ def expand(tokens, subs)
9
+ return [] unless tokens.is_a?(Array)
10
+
11
+ tokens.map do |tok|
12
+ tok.to_s.gsub(/%\{(\w+)\}/) do
13
+ k = Regexp.last_match(1)
14
+ subs[k] || subs[k.to_sym]&.to_s || ""
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,60 @@
1
+ # Default external CLI agents (override via ~/.config/ollama_agent/agents.yml or OLLAMA_AGENT_EXTERNAL_AGENTS_CONFIG).
2
+ # argv uses placeholders: binary, task_file, root — subshell interpolation is not supported (argv array only).
3
+ version: 1
4
+ agents:
5
+ - id: claude_cli
6
+ binary: claude
7
+ env_path: OLLAMA_AGENT_CLAUDE_CLI_PATH
8
+ capabilities:
9
+ - code_edit
10
+ - large_context
11
+ argv:
12
+ - "%{binary}"
13
+ - "-p"
14
+ - "%{task_file}"
15
+ version_argv:
16
+ - "%{binary}"
17
+ - "--version"
18
+ timeout_sec: 600
19
+
20
+ - id: gemini_cli
21
+ binary: gemini
22
+ env_path: OLLAMA_AGENT_GEMINI_CLI_PATH
23
+ capabilities:
24
+ - code_edit
25
+ argv:
26
+ - "%{binary}"
27
+ - "-p"
28
+ - "%{task_file}"
29
+ version_argv:
30
+ - "%{binary}"
31
+ - "--version"
32
+ timeout_sec: 600
33
+
34
+ - id: codex_cli
35
+ binary: codex
36
+ env_path: OLLAMA_AGENT_CODEX_CLI_PATH
37
+ capabilities:
38
+ - code_edit
39
+ argv:
40
+ - "%{binary}"
41
+ - "exec"
42
+ - "%{task_file}"
43
+ version_argv:
44
+ - "%{binary}"
45
+ - "--version"
46
+ timeout_sec: 600
47
+
48
+ - id: cursor_cli
49
+ binary: cursor
50
+ env_path: OLLAMA_AGENT_CURSOR_CLI_PATH
51
+ capabilities:
52
+ - code_edit
53
+ argv:
54
+ - "%{binary}"
55
+ - "-p"
56
+ - "%{task_file}"
57
+ version_argv:
58
+ - "%{binary}"
59
+ - "--version"
60
+ timeout_sec: 600
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "env_helpers"
6
+
7
+ module OllamaAgent
8
+ module ExternalAgents
9
+ # STDERR logging for delegate runs and shared debug lines (namespaced to avoid Ruby's ::Logger).
10
+ module DelegateLogger
11
+ class << self
12
+ def log_delegate_event(payload)
13
+ return unless delegate_log_enabled?
14
+
15
+ warn("ollama_agent_delegate: #{JSON.generate(payload)}")
16
+ rescue StandardError
17
+ nil
18
+ end
19
+
20
+ def debug(message)
21
+ warn("ollama_agent: #{message}") if EnvHelpers.env_bool?("OLLAMA_AGENT_DEBUG", default: false)
22
+ end
23
+
24
+ def delegate_log_enabled?
25
+ EnvHelpers.env_bool?("OLLAMA_AGENT_DELEGATE_LOG", default: false) ||
26
+ EnvHelpers.env_bool?("OLLAMA_AGENT_DEBUG", default: false)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module ExternalAgents
5
+ # Stand-in Process::Status when Open3 does not return (Timeout).
6
+ class DelegateTimeoutStatus
7
+ def exitstatus
8
+ 124
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module ExternalAgents
5
+ # Shared ENV parsing for delegate / external agent code paths.
6
+ module EnvHelpers
7
+ class << self
8
+ def env_present?(key)
9
+ v = ENV.fetch(key, nil)
10
+ return false if v.nil?
11
+
12
+ !v.to_s.strip.empty?
13
+ end
14
+
15
+ def env_bool?(key, default: false)
16
+ ENV.fetch(key, default ? "1" : "0").to_s == "1"
17
+ end
18
+
19
+ def env_positive_int(key, default)
20
+ v = ENV.fetch(key, nil)
21
+ return default if v.nil? || v.to_s.strip.empty?
22
+
23
+ Integer(v)
24
+ rescue ArgumentError, TypeError
25
+ default
26
+ end
27
+
28
+ def integer_or_default(raw, default)
29
+ return default if raw.nil?
30
+
31
+ Integer(raw)
32
+ rescue ArgumentError, TypeError
33
+ default
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module ExternalAgents
5
+ # Ensures paths stay under a project root (expanded, absolute).
6
+ module PathValidator
7
+ class << self
8
+ def validate_within_root!(root, paths)
9
+ root = File.expand_path(root)
10
+ Array(paths).each do |p|
11
+ next if p.to_s.strip.empty?
12
+
13
+ abs = File.expand_path(p, root)
14
+ next if within_root?(abs, root)
15
+
16
+ if ENV["OLLAMA_AGENT_DEBUG"] == "1"
17
+ warn "ollama_agent: PathValidator rejected path outside project root (#{p.inspect})"
18
+ end
19
+ raise ArgumentError, "path outside project root"
20
+ end
21
+ end
22
+
23
+ # Both arguments should already be expanded absolute paths (e.g. from Pathname or File.expand_path).
24
+ def within_root?(absolute_path, expanded_root)
25
+ expanded_root = File.expand_path(expanded_root)
26
+ abs = absolute_path.to_s
27
+ abs == expanded_root || abs.start_with?(expanded_root + File::SEPARATOR)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ require_relative "argv_interp"
6
+ require_relative "env_helpers"
7
+
8
+ module OllamaAgent
9
+ module ExternalAgents
10
+ # Resolves executables and optional --version for registry entries.
11
+ module Probe
12
+ class << self
13
+ # rubocop:disable Metrics/AbcSize
14
+ def resolve_executable(agent)
15
+ env_key = agent["env_path"]
16
+ if env_key && EnvHelpers.env_present?(env_key)
17
+ path = File.expand_path(ENV[env_key].to_s.strip)
18
+ return path if File.file?(path)
19
+ end
20
+
21
+ name = agent["binary"].to_s
22
+ return nil if name.empty?
23
+
24
+ out, status = Open3.capture2("command", "-v", name)
25
+ return out.strip if status.success? && !out.to_s.strip.empty?
26
+
27
+ nil
28
+ end
29
+ # rubocop:enable Metrics/AbcSize
30
+
31
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
32
+ def fetch_status(agent)
33
+ cache_key = status_cache_key(agent)
34
+ cached = status_cache[cache_key]
35
+ return cached.dup if cached
36
+
37
+ exe = resolve_executable(agent)
38
+ if exe.nil?
39
+ status = {
40
+ "id" => agent["id"].to_s,
41
+ "available" => false,
42
+ "path" => nil,
43
+ "version" => nil,
44
+ "capabilities" => agent["capabilities"] || [],
45
+ "error" => "executable not found (set #{agent["env_path"] || "PATH"})"
46
+ }
47
+ status_cache[cache_key] = status
48
+ return status.dup
49
+ end
50
+
51
+ status = {
52
+ "id" => agent["id"].to_s,
53
+ "available" => true,
54
+ "path" => exe,
55
+ "version" => capture_version(agent, exe),
56
+ "capabilities" => agent["capabilities"] || [],
57
+ "error" => nil
58
+ }
59
+ status_cache[cache_key] = status
60
+ status.dup
61
+ end
62
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
63
+
64
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
65
+ def capture_version(agent, exe)
66
+ argv_templ = agent["version_argv"]
67
+ return nil unless argv_templ.is_a?(Array) && !argv_templ.empty?
68
+
69
+ argv = ArgvInterp.expand(argv_templ, "binary" => exe, "task_file" => "", "root" => "")
70
+ out, err, status = Open3.capture3(*argv)
71
+ return nil unless status.success?
72
+
73
+ s = out.to_s.strip
74
+ s = err.to_s.strip if s.empty?
75
+ return nil if s.empty?
76
+
77
+ s.lines.first&.strip
78
+ rescue StandardError
79
+ nil
80
+ end
81
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
82
+
83
+ # rubocop:disable Metrics/MethodLength
84
+ def print_table(registry, io: $stdout)
85
+ io.puts "id\tok?\tpath\tversion\tcapabilities"
86
+ registry.agents.each do |a|
87
+ r = fetch_status(a)
88
+ io.puts [
89
+ r["id"],
90
+ r["available"] ? "yes" : "no",
91
+ r["path"] || "-",
92
+ (r["version"] || "-").to_s[0, 80],
93
+ (r["capabilities"] || []).join(",")
94
+ ].join("\t")
95
+ end
96
+ end
97
+ # rubocop:enable Metrics/MethodLength
98
+
99
+ def clear_cache!
100
+ status_cache.clear
101
+ end
102
+
103
+ def status_cache_key(agent)
104
+ env_key = agent["env_path"].to_s
105
+ env_val = env_key.empty? ? "" : ENV.fetch(env_key, "")
106
+ [
107
+ agent["id"].to_s,
108
+ agent["binary"].to_s,
109
+ env_key,
110
+ env_val
111
+ ].join("|")
112
+ end
113
+
114
+ private
115
+
116
+ def status_cache
117
+ @status_cache ||= {}
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module OllamaAgent
6
+ module ExternalAgents
7
+ # Loads merged default + user agent definitions (YAML).
8
+ class Registry
9
+ DEFAULT_FILE = File.join(__dir__, "default_agents.yml")
10
+ USER_FILE = File.join(Dir.home, ".config", "ollama_agent", "agents.yml")
11
+
12
+ attr_reader :agents
13
+
14
+ def initialize(agents)
15
+ @agents = agents
16
+ @by_id = agents.to_h { |a| [a["id"].to_s, a] }
17
+ end
18
+
19
+ def self.load
20
+ base = safe_read_yaml(DEFAULT_FILE)
21
+ user_path = ENV.fetch("OLLAMA_AGENT_EXTERNAL_AGENTS_CONFIG", nil)
22
+ user_path = USER_FILE if user_path.to_s.strip.empty? && File.file?(USER_FILE)
23
+ user = user_path && File.file?(user_path.to_s) ? safe_read_yaml(user_path) : {}
24
+ merged = merge_agents(base["agents"] || [], user["agents"] || [])
25
+ new(merged)
26
+ end
27
+
28
+ def self.safe_read_yaml(path)
29
+ YAML.safe_load(
30
+ File.read(path, encoding: Encoding::UTF_8),
31
+ permitted_classes: [],
32
+ aliases: true
33
+ ) || {}
34
+ end
35
+
36
+ def self.merge_agents(base, extra)
37
+ by_id = base.to_h { |a| [a["id"].to_s, a] }
38
+ extra.each do |a|
39
+ id = a["id"].to_s
40
+ by_id[id] = by_id[id] ? by_id[id].merge(a) : a
41
+ end
42
+ by_id.values
43
+ end
44
+
45
+ def find(id)
46
+ @by_id[id.to_s]
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "securerandom"
6
+ require "timeout"
7
+
8
+ require_relative "argv_interp"
9
+ require_relative "delegate_logger"
10
+ require_relative "delegate_timeout_status"
11
+ require_relative "env_helpers"
12
+ require_relative "path_validator"
13
+
14
+ module OllamaAgent
15
+ module ExternalAgents
16
+ # Runs external CLIs with cwd = project root; argv only (no shell).
17
+ module Runner
18
+ DEFAULT_MAX_OUTPUT = 100_000
19
+
20
+ class << self
21
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/ParameterLists
22
+ def run(agent_def:, root:, executable:, task:, context_summary:, paths:, timeout_sec:, max_output_bytes: nil)
23
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+ task_path = nil
25
+ paths = Array(paths).compact.map(&:to_s)
26
+ PathValidator.validate_within_root!(root, paths)
27
+
28
+ max_b = max_output_bytes || EnvHelpers.env_positive_int(
29
+ "OLLAMA_AGENT_DELEGATE_MAX_OUTPUT_BYTES",
30
+ DEFAULT_MAX_OUTPUT
31
+ )
32
+
33
+ handoff = build_handoff(task, context_summary, paths)
34
+ task_path = write_handoff(root, handoff)
35
+ argv = ArgvInterp.expand(
36
+ agent_def["argv"] || [],
37
+ "binary" => executable,
38
+ "task_file" => task_path,
39
+ "root" => root
40
+ )
41
+ return "Error: empty argv for agent #{agent_def["id"]}" if argv.empty?
42
+
43
+ out, err, status = capture_with_timeout(argv, root, timeout_sec)
44
+ code = status&.exitstatus
45
+ DelegateLogger.log_delegate_event(
46
+ {
47
+ event: "delegate_to_agent",
48
+ agent_id: agent_def["id"].to_s,
49
+ cwd: root.to_s,
50
+ argv: argv,
51
+ timeout_seconds: timeout_sec,
52
+ exit_code: code,
53
+ duration_ms: elapsed_ms(started_at),
54
+ env_keys: delegate_env_keys(agent_def)
55
+ }
56
+ )
57
+ combined = "exit:#{code}\n"
58
+ combined << "stdout:\n#{truncate(out.to_s, max_b)}\n"
59
+ combined << "stderr:\n#{truncate(err.to_s, max_b)}\n"
60
+ combined
61
+ ensure
62
+ File.unlink(task_path) if task_path && File.file?(task_path)
63
+ end
64
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/ParameterLists
65
+
66
+ def interpolate_argv(tokens, subs)
67
+ ArgvInterp.expand(tokens, subs)
68
+ end
69
+
70
+ def build_handoff(task, context_summary, paths)
71
+ parts = []
72
+ parts << "Task:\n#{task.to_s.strip}"
73
+ parts << "\nContext:\n#{context_summary.to_s.strip}" unless context_summary.to_s.strip.empty?
74
+ unless paths.empty?
75
+ path_lines = paths.map { |p| "- #{p}" }.join("\n")
76
+ parts << "\nRelevant paths (under project root):\n#{path_lines}"
77
+ end
78
+ parts.join("\n")
79
+ end
80
+
81
+ def write_handoff(root, text)
82
+ dir = File.join(root, ".ollama_agent")
83
+ FileUtils.mkdir_p(dir)
84
+ path = File.join(dir, "handoff-#{SecureRandom.hex(8)}.txt")
85
+ File.write(path, text, encoding: Encoding::UTF_8)
86
+ path
87
+ end
88
+
89
+ def capture_with_timeout(argv, root, timeout_sec)
90
+ Timeout.timeout(timeout_sec) do
91
+ Open3.capture3(*argv, chdir: root)
92
+ end
93
+ rescue Timeout::Error
94
+ ["", "ollama_agent: delegate timed out after #{timeout_sec}s", DelegateTimeoutStatus.new]
95
+ end
96
+
97
+ def truncate(str, max_bytes)
98
+ s = str.to_s
99
+ return s if s.bytesize <= max_bytes
100
+
101
+ "#{s.byteslice(0, max_bytes)}…\n[truncated: output exceeded #{max_bytes} bytes]"
102
+ end
103
+
104
+ def delegate_env_keys(agent_def)
105
+ keys = []
106
+ env_key = agent_def["env_path"].to_s
107
+ keys << env_key unless env_key.empty?
108
+ keys.concat(ENV.keys.grep(/\AOLLAMA_AGENT_/))
109
+ keys.uniq.sort
110
+ end
111
+
112
+ def elapsed_ms(started_at)
113
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "external_agents/env_helpers"
4
+ require_relative "external_agents/path_validator"
5
+ require_relative "external_agents/delegate_logger"
6
+ require_relative "external_agents/argv_interp"
7
+ require_relative "external_agents/registry"
8
+ require_relative "external_agents/probe"
9
+ require_relative "external_agents/runner"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dotenv"
4
+
5
+ module OllamaAgent
6
+ # The ollama-client gem runs +Dotenv.overload+ against the current working directory +.env+.
7
+ # When a global config file exists (XDG path or +OLLAMA_AGENT_DOTENV_PATH+), restore the
8
+ # environment from before that load and apply only that file so API keys are not picked up
9
+ # from arbitrary project trees.
10
+ module GlobalDotenv
11
+ module_function
12
+
13
+ def reconcile_after_ollama_client!(snapshot)
14
+ return if use_local_dotenv?
15
+ return unless (path = resolved_path) && File.file?(path)
16
+
17
+ ENV.replace(snapshot)
18
+ Dotenv.overload(path)
19
+ end
20
+
21
+ def use_local_dotenv?
22
+ ENV["OLLAMA_AGENT_USE_LOCAL_DOTENV"] == "1"
23
+ end
24
+
25
+ def resolved_path
26
+ custom = ENV["OLLAMA_AGENT_DOTENV_PATH"].to_s.strip
27
+ return File.expand_path(custom) unless custom.empty?
28
+
29
+ File.join(xdg_config_home, "ollama_agent", ".env")
30
+ end
31
+
32
+ def xdg_config_home
33
+ base = ENV["XDG_CONFIG_HOME"].to_s.strip
34
+ return File.expand_path(base) unless base.empty?
35
+
36
+ File.expand_path(File.join(Dir.home, ".config"))
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ollama_client"
4
+
5
+ module OllamaAgent
6
+ # Resolves the default chat model from the environment (after global dotenv reconciliation,
7
+ # these values come from ~/.config/ollama_agent/.env or the shell, not the project tree).
8
+ module ModelEnv
9
+ module_function
10
+
11
+ # @return [String, nil] model id from ENV when set (used to sync +Ollama::Config+ for Ollama Cloud).
12
+ def resolved_model_from_env
13
+ %w[OLLAMA_AGENT_MODEL OLLAMA_MODEL].each do |name|
14
+ value = ENV[name].to_s.strip
15
+ return value unless value.empty?
16
+ end
17
+
18
+ nil
19
+ end
20
+
21
+ # @return [String] model id from ENV or ollama-client default (+Ollama::Config.new.model+).
22
+ def default_chat_model
23
+ resolved_model_from_env || Ollama::Config.new.model
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "chat_stream_thinking_format"
4
+ require_relative "chat_stream_carry"
5
+
6
+ # ollama-client streams `message.content` via hooks[:on_token] but only appends `message.thinking`
7
+ # to a buffer. Forward thinking deltas so the CLI can render reasoning separately (Cursor-like).
8
+ module OllamaAgent
9
+ # Prepends {Ollama::Client::Chat} to invoke optional +hooks[:on_thinking]+ with each thinking chunk.
10
+ module OllamaChatThinkingStreamPatch
11
+ # rubocop:disable Metrics/ParameterLists -- signature must match ollama-client
12
+ def process_chat_stream_chunk(obj, hooks, full_content, full_thinking, full_logprobs, last_data)
13
+ normalize_stream_thinking_payload!(obj)
14
+ emit_streaming_thinking(hooks, obj)
15
+ ChatStreamCarry.stitch_done_message_tool_calls!(obj, last_data)
16
+ carry = ChatStreamCarry.next_last_data(last_data, obj)
17
+ super(obj, hooks, full_content, full_thinking, full_logprobs, carry)
18
+ end
19
+ # rubocop:enable Metrics/ParameterLists
20
+
21
+ private
22
+
23
+ def normalize_stream_thinking_payload!(obj)
24
+ return unless obj.is_a?(Hash) && obj["message"].is_a?(Hash)
25
+
26
+ ChatStreamThinkingFormat.normalize_message_thinking!(obj["message"])
27
+ end
28
+
29
+ def emit_streaming_thinking(hooks, obj)
30
+ return unless obj.is_a?(Hash) && obj["message"].is_a?(Hash)
31
+
32
+ th = obj["message"]["thinking"]
33
+ hooks[:on_thinking]&.call(th) if th.is_a?(String) && !th.empty?
34
+ end
35
+ end
36
+ end
37
+
38
+ if defined?(Ollama::Client::Chat) &&
39
+ Ollama::Client::Chat.private_method_defined?(:process_chat_stream_chunk, false)
40
+ Ollama::Client::Chat.prepend(OllamaAgent::OllamaChatThinkingStreamPatch)
41
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "model_env"
4
+
3
5
  module OllamaAgent
4
- # Maps OLLAMA_BASE_URL / OLLAMA_API_KEY into Ollama::Config (ollama-client convention for local vs cloud).
6
+ # Maps OLLAMA_BASE_URL / OLLAMA_API_KEY / chat model ENV into Ollama::Config (local vs Ollama Cloud).
5
7
  module OllamaConnection
6
8
  def self.apply_env_to_config(config)
7
9
  url = ENV.fetch("OLLAMA_BASE_URL", nil)
@@ -9,6 +11,9 @@ module OllamaAgent
9
11
 
10
12
  key = ENV.fetch("OLLAMA_API_KEY", nil)
11
13
  config.api_key = key if key && !key.strip.empty?
14
+
15
+ model = ModelEnv.resolved_model_from_env
16
+ config.model = model if model
12
17
  end
13
18
  end
14
19
  end