ollama_agent 0.3.0 → 1.0.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/CHANGELOG.md +23 -0
- data/README.md +14 -3
- data/lib/ollama_agent/agent/agent_config.rb +19 -2
- data/lib/ollama_agent/agent/client_wiring.rb +3 -8
- data/lib/ollama_agent/agent/session_wiring.rb +37 -3
- data/lib/ollama_agent/agent.rb +82 -6
- data/lib/ollama_agent/cli/repl.rb +159 -0
- data/lib/ollama_agent/cli/repl_shared.rb +229 -0
- data/lib/ollama_agent/cli/tui_repl.rb +149 -0
- data/lib/ollama_agent/cli.rb +129 -49
- data/lib/ollama_agent/core/action_envelope.rb +82 -0
- data/lib/ollama_agent/core/budget.rb +90 -0
- data/lib/ollama_agent/core/loop_detector.rb +67 -0
- data/lib/ollama_agent/core/schema_validator.rb +136 -0
- data/lib/ollama_agent/core/trace_logger.rb +138 -0
- data/lib/ollama_agent/external_agents/probe.rb +23 -3
- data/lib/ollama_agent/indexing/context_packer.rb +140 -0
- data/lib/ollama_agent/indexing/diff_summarizer.rb +125 -0
- data/lib/ollama_agent/indexing/file_indexer.rb +129 -0
- data/lib/ollama_agent/indexing/repo_scanner.rb +158 -0
- data/lib/ollama_agent/memory/long_term.rb +109 -0
- data/lib/ollama_agent/memory/manager.rb +121 -0
- data/lib/ollama_agent/memory/session_memory.rb +93 -0
- data/lib/ollama_agent/memory/short_term.rb +66 -0
- data/lib/ollama_agent/ollama_cloud_catalog.rb +66 -0
- data/lib/ollama_agent/ollama_connection.rb +30 -0
- data/lib/ollama_agent/plugins/loader.rb +95 -0
- data/lib/ollama_agent/plugins/registry.rb +103 -0
- data/lib/ollama_agent/providers/anthropic.rb +245 -0
- data/lib/ollama_agent/providers/base.rb +79 -0
- data/lib/ollama_agent/providers/ollama.rb +118 -0
- data/lib/ollama_agent/providers/openai.rb +215 -0
- data/lib/ollama_agent/providers/registry.rb +76 -0
- data/lib/ollama_agent/providers/router.rb +93 -0
- data/lib/ollama_agent/resilience/retry_middleware.rb +5 -0
- data/lib/ollama_agent/runner.rb +25 -4
- data/lib/ollama_agent/runtime/approval_gate.rb +74 -0
- data/lib/ollama_agent/runtime/permissions.rb +103 -0
- data/lib/ollama_agent/runtime/policies.rb +100 -0
- data/lib/ollama_agent/runtime/sandbox.rb +130 -0
- data/lib/ollama_agent/streaming/hooks.rb +3 -1
- data/lib/ollama_agent/tools/base.rb +108 -0
- data/lib/ollama_agent/tools/git_tools.rb +176 -0
- data/lib/ollama_agent/tools/http_tools.rb +202 -0
- data/lib/ollama_agent/tools/memory_tools.rb +116 -0
- data/lib/ollama_agent/tools/shell_tools.rb +208 -0
- data/lib/ollama_agent/tui.rb +183 -0
- data/lib/ollama_agent/tui_slash_reader.rb +147 -0
- data/lib/ollama_agent/tui_user_prompt.rb +45 -0
- data/lib/ollama_agent/version.rb +1 -1
- data/lib/ollama_agent.rb +46 -1
- metadata +142 -5
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "ollama"
|
|
5
|
+
require_relative "openai"
|
|
6
|
+
require_relative "anthropic"
|
|
7
|
+
require_relative "router"
|
|
8
|
+
|
|
9
|
+
module OllamaAgent
|
|
10
|
+
module Providers
|
|
11
|
+
# Central registry for model providers.
|
|
12
|
+
# Resolves a provider by name string or symbol and builds the Router.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# provider = OllamaAgent::Providers::Registry.resolve("openai")
|
|
16
|
+
# OllamaAgent::Providers::Registry.register("my_provider", MyProvider)
|
|
17
|
+
module Registry
|
|
18
|
+
BUILT_IN = {
|
|
19
|
+
"ollama" => Ollama,
|
|
20
|
+
"openai" => OpenAI,
|
|
21
|
+
"anthropic" => Anthropic
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
@custom = {}
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Register a custom provider class.
|
|
28
|
+
def register(name, klass)
|
|
29
|
+
raise ArgumentError, "#{klass} must inherit from Providers::Base" unless klass <= Base
|
|
30
|
+
|
|
31
|
+
@custom[name.to_s] = klass
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Resolve a provider instance by name.
|
|
35
|
+
# @param name [String, Symbol] provider name or "auto"
|
|
36
|
+
# @param opts [Hash] forwarded to the provider constructor
|
|
37
|
+
# @return [Base] provider instance
|
|
38
|
+
def resolve(name, **opts)
|
|
39
|
+
return auto_provider(**opts) if name.to_s == "auto"
|
|
40
|
+
|
|
41
|
+
klass = @custom[name.to_s] || BUILT_IN[name.to_s]
|
|
42
|
+
raise ArgumentError, "Unknown provider: #{name}. Known: #{known_names.join(", ")}" unless klass
|
|
43
|
+
|
|
44
|
+
klass.new(**opts)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Build a router from a priority list of provider names.
|
|
48
|
+
# @param names [Array<String>] provider names in fallback order
|
|
49
|
+
def router(names, strategy: :first_available, **shared_opts)
|
|
50
|
+
providers = names.map { |n| resolve(n, **shared_opts) }
|
|
51
|
+
Router.new(providers: providers, strategy: strategy)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns a provider that is available right now.
|
|
55
|
+
# Checks Ollama first (free/local), then OpenAI, then Anthropic.
|
|
56
|
+
def auto_provider(**opts)
|
|
57
|
+
candidates = [
|
|
58
|
+
BUILT_IN["ollama"].new(**opts),
|
|
59
|
+
(BUILT_IN["openai"].new(**opts) if ENV["OPENAI_API_KEY"]),
|
|
60
|
+
(BUILT_IN["anthropic"].new(**opts) if ENV["ANTHROPIC_API_KEY"])
|
|
61
|
+
].compact
|
|
62
|
+
|
|
63
|
+
Router.new(providers: candidates, strategy: :first_available)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def known_names
|
|
67
|
+
(BUILT_IN.keys + @custom.keys).uniq
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def reset_custom!
|
|
71
|
+
@custom = {}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module OllamaAgent
|
|
6
|
+
module Providers
|
|
7
|
+
# Routes chat requests to the first available provider.
|
|
8
|
+
# Falls back through the list on errors or unavailability.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# router = OllamaAgent::Providers::Router.new(
|
|
12
|
+
# providers: [
|
|
13
|
+
# OllamaAgent::Providers::Ollama.new,
|
|
14
|
+
# OllamaAgent::Providers::OpenAI.new(api_key: ENV["OPENAI_API_KEY"])
|
|
15
|
+
# ],
|
|
16
|
+
# strategy: :first_available # or :round_robin, :cheapest
|
|
17
|
+
# )
|
|
18
|
+
class Router < Base
|
|
19
|
+
STRATEGIES = %i[first_available round_robin cheapest].freeze
|
|
20
|
+
|
|
21
|
+
ProviderUnavailableError = Class.new(defined?(OllamaAgent::Error) ? OllamaAgent::Error : StandardError)
|
|
22
|
+
|
|
23
|
+
def initialize(providers:, strategy: :first_available, on_fallback: nil, **opts)
|
|
24
|
+
super(name: "router", **opts)
|
|
25
|
+
@providers = Array(providers)
|
|
26
|
+
@strategy = STRATEGIES.include?(strategy.to_sym) ? strategy.to_sym : :first_available
|
|
27
|
+
@on_fallback = on_fallback # optional: lambda(from, to, reason)
|
|
28
|
+
@rr_index = 0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def chat(messages:, model:, **kwargs)
|
|
32
|
+
candidates = ordered_providers
|
|
33
|
+
last_error = nil
|
|
34
|
+
|
|
35
|
+
candidates.each do |provider|
|
|
36
|
+
next unless provider.available?
|
|
37
|
+
|
|
38
|
+
return provider.chat(messages: messages, model: model, **kwargs)
|
|
39
|
+
rescue OllamaAgent::Error, StandardError => e
|
|
40
|
+
last_error = e
|
|
41
|
+
next_provider = candidates[candidates.index(provider).to_i + 1]
|
|
42
|
+
if next_provider
|
|
43
|
+
@on_fallback&.call(provider.name, next_provider.name, e.message)
|
|
44
|
+
end
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
raise ProviderUnavailableError, build_unavailable_message(last_error)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def available?
|
|
52
|
+
@providers.any?(&:available?)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def available_providers
|
|
56
|
+
@providers.select(&:available?)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def provider_status
|
|
60
|
+
@providers.map do |p|
|
|
61
|
+
{ name: p.name, available: p.available?, streaming: p.streaming_supported? }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def ordered_providers
|
|
68
|
+
case @strategy
|
|
69
|
+
when :round_robin then round_robin_order
|
|
70
|
+
when :cheapest then cheapest_order
|
|
71
|
+
else @providers.dup # first_available
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def round_robin_order
|
|
76
|
+
n = @providers.size
|
|
77
|
+
@rr_index = (@rr_index + 1) % n
|
|
78
|
+
@providers.rotate(@rr_index)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def cheapest_order
|
|
82
|
+
# Sort by estimated cost per token (use a probe call with 0 tokens as proxy)
|
|
83
|
+
@providers.sort_by { |p| p.estimate_cost(input_tokens: 1000, output_tokens: 500) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_unavailable_message(last_error)
|
|
87
|
+
names = @providers.map(&:name).join(", ")
|
|
88
|
+
base = "No providers available (tried: #{names})"
|
|
89
|
+
last_error ? "#{base}. Last error: #{last_error.message}" : base
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/ollama_agent/runner.rb
CHANGED
|
@@ -39,6 +39,11 @@ module OllamaAgent
|
|
|
39
39
|
# @param http_timeout [Integer, nil] HTTP timeout in seconds
|
|
40
40
|
# @param stdin [IO] input for patch/write/delegate confirmations (default +$stdin+)
|
|
41
41
|
# @param stdout [IO] output for confirmation prompts (default +$stdout+)
|
|
42
|
+
# @param provider [String, nil] provider name: "ollama" | "openai" | "anthropic" | "auto" (v2)
|
|
43
|
+
# @param permissions [Runtime::Permissions, nil] tool permission profile (v2)
|
|
44
|
+
# @param budget [Core::Budget, nil] token/step budget (v2)
|
|
45
|
+
# @param memory [Memory::Manager, nil] memory manager instance (v2)
|
|
46
|
+
# @param trace [Boolean] enable trace logging to stdout (v2)
|
|
42
47
|
# @return [Runner]
|
|
43
48
|
# rubocop:disable Metrics/ParameterLists -- library facade must expose all Agent options
|
|
44
49
|
def self.build(
|
|
@@ -59,7 +64,13 @@ module OllamaAgent
|
|
|
59
64
|
think: nil,
|
|
60
65
|
http_timeout: nil,
|
|
61
66
|
stdin: $stdin,
|
|
62
|
-
stdout: $stdout
|
|
67
|
+
stdout: $stdout,
|
|
68
|
+
# v2 platform options
|
|
69
|
+
provider: nil,
|
|
70
|
+
permissions: nil,
|
|
71
|
+
budget: nil,
|
|
72
|
+
memory: nil,
|
|
73
|
+
trace: false
|
|
63
74
|
)
|
|
64
75
|
new(
|
|
65
76
|
root: root, model: model, stream: stream,
|
|
@@ -69,7 +80,9 @@ module OllamaAgent
|
|
|
69
80
|
skills_enabled: skills_enabled, skill_paths: skill_paths,
|
|
70
81
|
confirm_patches: confirm_patches, orchestrator: orchestrator,
|
|
71
82
|
think: think, http_timeout: http_timeout,
|
|
72
|
-
stdin: stdin, stdout: stdout
|
|
83
|
+
stdin: stdin, stdout: stdout,
|
|
84
|
+
provider: provider, permissions: permissions,
|
|
85
|
+
budget: budget, memory: memory, trace: trace
|
|
73
86
|
)
|
|
74
87
|
end
|
|
75
88
|
# rubocop:enable Metrics/ParameterLists
|
|
@@ -92,9 +105,12 @@ module OllamaAgent
|
|
|
92
105
|
max_tokens:, context_summarize:,
|
|
93
106
|
max_retries:, audit:, read_only:, skills_enabled:, skill_paths:,
|
|
94
107
|
confirm_patches:, orchestrator:, think:, http_timeout:,
|
|
95
|
-
stdin:, stdout
|
|
108
|
+
stdin:, stdout:,
|
|
109
|
+
provider: nil, permissions: nil, budget: nil, memory: nil, trace: false)
|
|
96
110
|
@session_id = session_id
|
|
97
111
|
|
|
112
|
+
trace_logger = trace ? Core::TraceLogger.new(format: :human) : nil
|
|
113
|
+
|
|
98
114
|
config = Agent::AgentConfig.new(
|
|
99
115
|
root: root,
|
|
100
116
|
model: model,
|
|
@@ -112,7 +128,12 @@ module OllamaAgent
|
|
|
112
128
|
max_tokens: max_tokens,
|
|
113
129
|
context_summarize: context_summarize,
|
|
114
130
|
stdin: stdin,
|
|
115
|
-
stdout: stdout
|
|
131
|
+
stdout: stdout,
|
|
132
|
+
provider_name: provider,
|
|
133
|
+
permissions: permissions,
|
|
134
|
+
budget: budget,
|
|
135
|
+
memory_manager: memory,
|
|
136
|
+
trace_logger: trace_logger
|
|
116
137
|
)
|
|
117
138
|
@agent = Agent.new(config: config)
|
|
118
139
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OllamaAgent
|
|
4
|
+
module Runtime
|
|
5
|
+
# Governs whether a tool call or action requires explicit user approval.
|
|
6
|
+
#
|
|
7
|
+
# Policy precedence (highest to lowest):
|
|
8
|
+
# 1. auto_approve: true — approve everything silently
|
|
9
|
+
# 2. Tool's requires_approval flag
|
|
10
|
+
# 3. Registered per-tool overrides
|
|
11
|
+
# 4. Risk-level threshold
|
|
12
|
+
class ApprovalGate
|
|
13
|
+
RISK_ORDER = { low: 0, medium: 1, high: 2, critical: 3 }.freeze
|
|
14
|
+
|
|
15
|
+
# @param auto_approve [Boolean] skip all approvals
|
|
16
|
+
# @param risk_threshold [Symbol] auto-approve tools below this risk (:low, :medium, :high, :critical)
|
|
17
|
+
# @param tool_overrides [Hash] { "tool_name" => true/false } per-tool override
|
|
18
|
+
# @param stdin [IO]
|
|
19
|
+
# @param stdout [IO]
|
|
20
|
+
def initialize(auto_approve: false, risk_threshold: :medium,
|
|
21
|
+
tool_overrides: {}, stdin: $stdin, stdout: $stdout)
|
|
22
|
+
@auto_approve = auto_approve
|
|
23
|
+
@risk_threshold = risk_threshold.to_sym
|
|
24
|
+
@tool_overrides = tool_overrides.transform_keys(&:to_s)
|
|
25
|
+
@stdin = stdin
|
|
26
|
+
@stdout = stdout
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Decide whether this tool call is approved.
|
|
30
|
+
# @param tool_name [String]
|
|
31
|
+
# @param args [Hash]
|
|
32
|
+
# @param risk_level [Symbol] from the tool definition
|
|
33
|
+
# @param approval_required [Boolean] from the tool definition
|
|
34
|
+
# @return [Boolean]
|
|
35
|
+
def approved?(tool_name, args: {}, risk_level: :low, approval_required: false)
|
|
36
|
+
return true if @auto_approve
|
|
37
|
+
return @tool_overrides[tool_name.to_s] if @tool_overrides.key?(tool_name.to_s)
|
|
38
|
+
return true unless needs_gate?(risk_level, approval_required)
|
|
39
|
+
|
|
40
|
+
prompt_user(tool_name, args, risk_level)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Record a decision (useful for tests / audit).
|
|
44
|
+
attr_reader :last_decision
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def needs_gate?(risk_level, approval_required)
|
|
49
|
+
return true if approval_required
|
|
50
|
+
|
|
51
|
+
RISK_ORDER[risk_level.to_sym].to_i >= RISK_ORDER[@risk_threshold].to_i
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def prompt_user(tool_name, args, risk_level)
|
|
55
|
+
@stdout.puts ""
|
|
56
|
+
@stdout.puts "─" * 60
|
|
57
|
+
@stdout.puts " Approval required: \e[33m#{tool_name}\e[0m (risk: #{risk_level})"
|
|
58
|
+
unless args.empty?
|
|
59
|
+
@stdout.puts " Args:"
|
|
60
|
+
args.each { |k, v| @stdout.puts " #{k}: #{v.inspect[0, 80]}" }
|
|
61
|
+
end
|
|
62
|
+
@stdout.puts "─" * 60
|
|
63
|
+
@stdout.print " Allow? [y/N] "
|
|
64
|
+
@stdout.flush
|
|
65
|
+
|
|
66
|
+
response = @stdin.gets&.chomp.to_s.downcase
|
|
67
|
+
@last_decision = { tool: tool_name, approved: response == "y" }
|
|
68
|
+
response == "y"
|
|
69
|
+
rescue IOError
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OllamaAgent
|
|
4
|
+
module Runtime
|
|
5
|
+
# Tool permission system. Controls which tools are accessible in a given run.
|
|
6
|
+
#
|
|
7
|
+
# Built-in profiles:
|
|
8
|
+
# :read_only — file reads + search only
|
|
9
|
+
# :standard — read + write files, no shell or git writes
|
|
10
|
+
# :developer — full file + git + shell tools
|
|
11
|
+
# :full — everything
|
|
12
|
+
class Permissions
|
|
13
|
+
PROFILES = {
|
|
14
|
+
read_only: {
|
|
15
|
+
allowed: %w[read_file list_files search_code git_status git_log git_diff
|
|
16
|
+
memory_recall memory_list http_get],
|
|
17
|
+
denied: []
|
|
18
|
+
},
|
|
19
|
+
standard: {
|
|
20
|
+
allowed: %w[read_file list_files search_code edit_file write_file
|
|
21
|
+
memory_store memory_recall memory_list memory_delete
|
|
22
|
+
git_status git_log git_diff http_get],
|
|
23
|
+
denied: %w[run_shell git_commit http_post]
|
|
24
|
+
},
|
|
25
|
+
developer: {
|
|
26
|
+
allowed: %w[read_file list_files search_code edit_file write_file
|
|
27
|
+
git_status git_log git_diff git_commit git_branch
|
|
28
|
+
run_shell memory_store memory_recall memory_list memory_delete
|
|
29
|
+
http_get],
|
|
30
|
+
denied: %w[http_post]
|
|
31
|
+
},
|
|
32
|
+
full: {
|
|
33
|
+
allowed: :all,
|
|
34
|
+
denied: []
|
|
35
|
+
}
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# @param profile [Symbol] one of PROFILES keys
|
|
39
|
+
# @param allowed [Array, :all] explicit tool allowlist (overrides profile)
|
|
40
|
+
# @param denied [Array] explicit denylist (always wins)
|
|
41
|
+
def initialize(profile: :standard, allowed: nil, denied: nil)
|
|
42
|
+
@profile = profile.to_sym
|
|
43
|
+
@custom_allowed = allowed
|
|
44
|
+
@custom_denied = Array(denied).map(&:to_s)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Is this tool allowed?
|
|
48
|
+
# @param tool_name [String, Symbol]
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def allowed?(tool_name)
|
|
51
|
+
name = tool_name.to_s
|
|
52
|
+
|
|
53
|
+
return false if effective_denied.include?(name)
|
|
54
|
+
|
|
55
|
+
eff_allowed = effective_allowed
|
|
56
|
+
return true if eff_allowed == :all
|
|
57
|
+
|
|
58
|
+
eff_allowed.include?(name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Filtered list of tool schemas — only allowed tools.
|
|
62
|
+
def filter_schemas(schemas)
|
|
63
|
+
schemas.select { |s| allowed?(schema_name(s)) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def profile
|
|
67
|
+
@profile
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_h
|
|
71
|
+
{
|
|
72
|
+
profile: @profile,
|
|
73
|
+
effective_allowed: effective_allowed,
|
|
74
|
+
effective_denied: effective_denied
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def profile_config
|
|
81
|
+
PROFILES[@profile] || PROFILES[:standard]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def effective_allowed
|
|
85
|
+
return @custom_allowed if @custom_allowed
|
|
86
|
+
|
|
87
|
+
profile_config[:allowed]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def effective_denied
|
|
91
|
+
(profile_config[:denied] + @custom_denied).uniq
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def schema_name(schema)
|
|
95
|
+
schema.dig(:function, :name) ||
|
|
96
|
+
schema.dig("function", "name") ||
|
|
97
|
+
schema[:name] ||
|
|
98
|
+
schema["name"] ||
|
|
99
|
+
""
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OllamaAgent
|
|
4
|
+
module Runtime
|
|
5
|
+
# Policy engine for the agent runtime.
|
|
6
|
+
#
|
|
7
|
+
# A Policy is a named rule that can:
|
|
8
|
+
# - Block a tool call (returns a rejection reason string)
|
|
9
|
+
# - Allow it (returns nil / :allow)
|
|
10
|
+
# - Modify args before execution
|
|
11
|
+
#
|
|
12
|
+
# Policies are evaluated in registration order.
|
|
13
|
+
# First blocking policy wins.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# policies = OllamaAgent::Runtime::Policies.new
|
|
17
|
+
# policies.add(:no_outside_writes) do |tool, args, ctx|
|
|
18
|
+
# if tool == "write_file" && !args["path"]&.start_with?(ctx[:root])
|
|
19
|
+
# "write_file: cannot write outside project root"
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
class Policies
|
|
23
|
+
Policy = Data.define(:name, :handler)
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@policies = []
|
|
27
|
+
install_default_policies
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Register a policy.
|
|
31
|
+
# @param name [Symbol, String]
|
|
32
|
+
# @param handler [Proc] receives (tool_name, args, context) → nil | String (rejection reason)
|
|
33
|
+
def add(name, &handler)
|
|
34
|
+
raise ArgumentError, "Policy handler block required" unless block_given?
|
|
35
|
+
|
|
36
|
+
@policies << Policy.new(name: name.to_sym, handler: handler)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Remove a named policy.
|
|
40
|
+
def remove(name)
|
|
41
|
+
@policies.reject! { |p| p.name == name.to_sym }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Evaluate all policies for a tool call.
|
|
45
|
+
# @return [nil, String] nil = allowed; String = rejection reason
|
|
46
|
+
def evaluate(tool_name, args, context = {})
|
|
47
|
+
@policies.each do |policy|
|
|
48
|
+
result = policy.handler.call(tool_name.to_s, args, context)
|
|
49
|
+
return result.to_s if result && result != :allow
|
|
50
|
+
end
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Check read_only at the policy level
|
|
55
|
+
def blocked?(tool_name, args, context = {})
|
|
56
|
+
!evaluate(tool_name, args, context).nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def policy_names
|
|
60
|
+
@policies.map(&:name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def install_default_policies
|
|
66
|
+
# Enforce read_only context flag
|
|
67
|
+
add(:read_only_enforcement) do |tool, _args, ctx|
|
|
68
|
+
write_tools = %w[edit_file write_file run_shell git_commit http_post memory_delete]
|
|
69
|
+
if ctx[:read_only] && write_tools.include?(tool)
|
|
70
|
+
"#{tool} is not allowed in read-only mode"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Prevent writing outside project root for file tools
|
|
75
|
+
add(:path_sandbox_enforcement) do |tool, args, ctx|
|
|
76
|
+
next unless ctx[:root] && %w[edit_file write_file read_file].include?(tool)
|
|
77
|
+
|
|
78
|
+
path = (args["path"] || args[:path]).to_s
|
|
79
|
+
next if path.empty?
|
|
80
|
+
|
|
81
|
+
expanded = File.expand_path(path, ctx[:root])
|
|
82
|
+
root_abs = File.expand_path(ctx[:root])
|
|
83
|
+
|
|
84
|
+
unless expanded.start_with?(root_abs)
|
|
85
|
+
"#{tool}: path must stay within project root (#{root_abs})"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Rate-limit shell commands (max 10 per run)
|
|
90
|
+
add(:shell_rate_limit) do |tool, _args, ctx|
|
|
91
|
+
next unless tool == "run_shell"
|
|
92
|
+
|
|
93
|
+
counter = (ctx[:shell_call_count] || 0)
|
|
94
|
+
limit = (ctx[:shell_call_limit] || 10)
|
|
95
|
+
"run_shell: rate limit of #{limit} calls per run exceeded" if counter >= limit
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module OllamaAgent
|
|
7
|
+
module Runtime
|
|
8
|
+
# Workspace sandbox: copies a project to a temp directory for isolated edits.
|
|
9
|
+
# Used by self-improvement and automated modes to prevent live-tree mutations.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# sandbox = OllamaAgent::Runtime::Sandbox.new(source_root: "/my/project")
|
|
13
|
+
# sandbox.setup!
|
|
14
|
+
# # agent runs inside sandbox.root
|
|
15
|
+
# sandbox.changed_files # => list of modified files
|
|
16
|
+
# sandbox.sync_back!(target: "/my/project") # merge changes
|
|
17
|
+
# sandbox.teardown!
|
|
18
|
+
class Sandbox
|
|
19
|
+
attr_reader :root, :source_root
|
|
20
|
+
|
|
21
|
+
IGNORED_DIRS = %w[.git node_modules vendor .bundle tmp log coverage .ollama_agent].freeze
|
|
22
|
+
|
|
23
|
+
def initialize(source_root:, prefix: "ollama_agent_sandbox")
|
|
24
|
+
@source_root = File.expand_path(source_root)
|
|
25
|
+
@prefix = prefix
|
|
26
|
+
@root = nil
|
|
27
|
+
@setup_done = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Copy source tree to a temp directory.
|
|
31
|
+
# @return [String] sandbox root path
|
|
32
|
+
def setup!
|
|
33
|
+
@root = Dir.mktmpdir(@prefix)
|
|
34
|
+
copy_tree(@source_root, @root)
|
|
35
|
+
@setup_done = true
|
|
36
|
+
@root
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns relative paths of files changed inside the sandbox vs the original.
|
|
40
|
+
def changed_files
|
|
41
|
+
return [] unless @setup_done
|
|
42
|
+
|
|
43
|
+
find_files(@root).each_with_object([]) do |abs_path, changed|
|
|
44
|
+
rel = abs_path.sub("#{@root}/", "")
|
|
45
|
+
orig = File.join(@source_root, rel)
|
|
46
|
+
changed << rel if !File.exist?(orig) || File.read(abs_path) != File.read(orig)
|
|
47
|
+
rescue StandardError
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Files present in source but deleted in sandbox.
|
|
53
|
+
def deleted_files
|
|
54
|
+
return [] unless @setup_done
|
|
55
|
+
|
|
56
|
+
find_files(@source_root).each_with_object([]) do |abs_path, deleted|
|
|
57
|
+
rel = abs_path.sub("#{@source_root}/", "")
|
|
58
|
+
deleted << rel unless File.exist?(File.join(@root, rel))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Copy changed sandbox files back to target (defaults to source_root).
|
|
63
|
+
# @param target [String] destination directory
|
|
64
|
+
# @param only_files [Array] restrict to specific relative paths
|
|
65
|
+
# @return [Array<String>] list of relative paths copied
|
|
66
|
+
def sync_back!(target: nil, only_files: nil)
|
|
67
|
+
raise "Sandbox not set up" unless @setup_done
|
|
68
|
+
|
|
69
|
+
dest = File.expand_path(target || @source_root)
|
|
70
|
+
to_copy = only_files || changed_files
|
|
71
|
+
|
|
72
|
+
to_copy.each do |rel|
|
|
73
|
+
src = File.join(@root, rel)
|
|
74
|
+
dst = File.join(dest, rel)
|
|
75
|
+
FileUtils.mkdir_p(File.dirname(dst))
|
|
76
|
+
FileUtils.cp(src, dst)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
to_copy
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Remove the temporary directory.
|
|
83
|
+
def teardown!
|
|
84
|
+
return unless @root && File.directory?(@root)
|
|
85
|
+
|
|
86
|
+
FileUtils.rm_rf(@root)
|
|
87
|
+
@root = nil
|
|
88
|
+
@setup_done = false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Run a block inside the sandbox, then teardown.
|
|
92
|
+
def use
|
|
93
|
+
setup!
|
|
94
|
+
yield self
|
|
95
|
+
ensure
|
|
96
|
+
teardown!
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def copy_tree(src, dst)
|
|
102
|
+
Dir.foreach(src) do |entry|
|
|
103
|
+
next if entry.start_with?(".")
|
|
104
|
+
next if IGNORED_DIRS.include?(entry)
|
|
105
|
+
|
|
106
|
+
src_path = File.join(src, entry)
|
|
107
|
+
dst_path = File.join(dst, entry)
|
|
108
|
+
|
|
109
|
+
if File.directory?(src_path)
|
|
110
|
+
FileUtils.mkdir_p(dst_path)
|
|
111
|
+
copy_tree(src_path, dst_path)
|
|
112
|
+
else
|
|
113
|
+
FileUtils.cp(src_path, dst_path)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def find_files(dir)
|
|
119
|
+
files = []
|
|
120
|
+
Dir.glob(File.join(dir, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
121
|
+
next if File.directory?(path)
|
|
122
|
+
next if IGNORED_DIRS.any? { |d| path.include?("/#{d}/") }
|
|
123
|
+
|
|
124
|
+
files << path
|
|
125
|
+
end
|
|
126
|
+
files
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -5,7 +5,9 @@ module OllamaAgent
|
|
|
5
5
|
# Lightweight event bus for agent lifecycle events.
|
|
6
6
|
# All layers share one Hooks instance per Agent run.
|
|
7
7
|
class Hooks
|
|
8
|
-
EVENTS = %i[
|
|
8
|
+
EVENTS = %i[
|
|
9
|
+
on_token on_thinking on_chunk on_tool_call on_tool_result on_assistant_message on_complete on_error on_retry
|
|
10
|
+
].freeze
|
|
9
11
|
|
|
10
12
|
MISSING_BLOCK_MESSAGE = "Hooks require a block when registering a handler"
|
|
11
13
|
|