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,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,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 (
|
|
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
|