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,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
|
-
|
|
43
|
-
@
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|