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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +14 -3
  4. data/lib/ollama_agent/agent/agent_config.rb +19 -2
  5. data/lib/ollama_agent/agent/client_wiring.rb +3 -8
  6. data/lib/ollama_agent/agent/session_wiring.rb +37 -3
  7. data/lib/ollama_agent/agent.rb +82 -6
  8. data/lib/ollama_agent/cli/repl.rb +159 -0
  9. data/lib/ollama_agent/cli/repl_shared.rb +229 -0
  10. data/lib/ollama_agent/cli/tui_repl.rb +149 -0
  11. data/lib/ollama_agent/cli.rb +129 -49
  12. data/lib/ollama_agent/core/action_envelope.rb +82 -0
  13. data/lib/ollama_agent/core/budget.rb +90 -0
  14. data/lib/ollama_agent/core/loop_detector.rb +67 -0
  15. data/lib/ollama_agent/core/schema_validator.rb +136 -0
  16. data/lib/ollama_agent/core/trace_logger.rb +138 -0
  17. data/lib/ollama_agent/external_agents/probe.rb +23 -3
  18. data/lib/ollama_agent/indexing/context_packer.rb +140 -0
  19. data/lib/ollama_agent/indexing/diff_summarizer.rb +125 -0
  20. data/lib/ollama_agent/indexing/file_indexer.rb +129 -0
  21. data/lib/ollama_agent/indexing/repo_scanner.rb +158 -0
  22. data/lib/ollama_agent/memory/long_term.rb +109 -0
  23. data/lib/ollama_agent/memory/manager.rb +121 -0
  24. data/lib/ollama_agent/memory/session_memory.rb +93 -0
  25. data/lib/ollama_agent/memory/short_term.rb +66 -0
  26. data/lib/ollama_agent/ollama_cloud_catalog.rb +66 -0
  27. data/lib/ollama_agent/ollama_connection.rb +30 -0
  28. data/lib/ollama_agent/plugins/loader.rb +95 -0
  29. data/lib/ollama_agent/plugins/registry.rb +103 -0
  30. data/lib/ollama_agent/providers/anthropic.rb +245 -0
  31. data/lib/ollama_agent/providers/base.rb +79 -0
  32. data/lib/ollama_agent/providers/ollama.rb +118 -0
  33. data/lib/ollama_agent/providers/openai.rb +215 -0
  34. data/lib/ollama_agent/providers/registry.rb +76 -0
  35. data/lib/ollama_agent/providers/router.rb +93 -0
  36. data/lib/ollama_agent/resilience/retry_middleware.rb +5 -0
  37. data/lib/ollama_agent/runner.rb +25 -4
  38. data/lib/ollama_agent/runtime/approval_gate.rb +74 -0
  39. data/lib/ollama_agent/runtime/permissions.rb +103 -0
  40. data/lib/ollama_agent/runtime/policies.rb +100 -0
  41. data/lib/ollama_agent/runtime/sandbox.rb +130 -0
  42. data/lib/ollama_agent/streaming/hooks.rb +3 -1
  43. data/lib/ollama_agent/tools/base.rb +108 -0
  44. data/lib/ollama_agent/tools/git_tools.rb +176 -0
  45. data/lib/ollama_agent/tools/http_tools.rb +202 -0
  46. data/lib/ollama_agent/tools/memory_tools.rb +116 -0
  47. data/lib/ollama_agent/tools/shell_tools.rb +208 -0
  48. data/lib/ollama_agent/tui.rb +183 -0
  49. data/lib/ollama_agent/tui_slash_reader.rb +147 -0
  50. data/lib/ollama_agent/tui_user_prompt.rb +45 -0
  51. data/lib/ollama_agent/version.rb +1 -1
  52. data/lib/ollama_agent.rb +46 -1
  53. 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
@@ -35,6 +35,11 @@ module OllamaAgent
35
35
  end
36
36
  # rubocop:enable Metrics/MethodLength
37
37
 
38
+ # Delegates to the inner Ollama client (+/api/tags+).
39
+ def list_model_names
40
+ @client.list_model_names
41
+ end
42
+
38
43
  private
39
44
 
40
45
  def http_error_non_retryable?(error)
@@ -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[on_token on_thinking on_chunk on_tool_call on_tool_result on_complete on_error on_retry].freeze
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