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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module OllamaAgent
7
+ module Memory
8
+ # Session-scoped key-value store for goals, task progress, and intermediate state.
9
+ # Persisted to YAML under .ollama_agent/memory/<session_id>.yml.
10
+ # Outlives a single run but is scoped to a session.
11
+ class SessionMemory
12
+ attr_reader :session_id
13
+
14
+ def initialize(root:, session_id: nil)
15
+ @root = File.expand_path(root)
16
+ @session_id = session_id || "default"
17
+ @store = load_or_initialize
18
+ end
19
+
20
+ # @param key [String, Symbol]
21
+ # @param value [Object] must be YAML-serialisable
22
+ def set(key, value)
23
+ @store[key.to_s] = value
24
+ persist!
25
+ value
26
+ end
27
+
28
+ def get(key)
29
+ @store[key.to_s]
30
+ end
31
+
32
+ def delete(key)
33
+ @store.delete(key.to_s)
34
+ persist!
35
+ end
36
+
37
+ def keys
38
+ @store.keys
39
+ end
40
+
41
+ def all
42
+ @store.dup
43
+ end
44
+
45
+ def clear!
46
+ @store = {}
47
+ persist!
48
+ end
49
+
50
+ # Active goals tracking
51
+ def set_goal(description)
52
+ goals = @store.fetch("_goals", [])
53
+ goals << { description: description, status: "active", added_at: Time.now.iso8601 }
54
+ set("_goals", goals)
55
+ end
56
+
57
+ def complete_goal(description)
58
+ goals = @store.fetch("_goals", [])
59
+ goals.each { |g| g[:status] = "done" if g[:description] == description }
60
+ set("_goals", goals)
61
+ end
62
+
63
+ def active_goals
64
+ @store.fetch("_goals", []).select { |g| g[:status] == "active" }
65
+ end
66
+
67
+ private
68
+
69
+ def memory_dir
70
+ File.join(@root, ".ollama_agent", "memory")
71
+ end
72
+
73
+ def memory_path
74
+ File.join(memory_dir, "#{@session_id}.yml")
75
+ end
76
+
77
+ def load_or_initialize
78
+ return {} unless File.exist?(memory_path)
79
+
80
+ YAML.safe_load_file(memory_path, permitted_classes: [Symbol, Time]) || {}
81
+ rescue StandardError
82
+ {}
83
+ end
84
+
85
+ def persist!
86
+ FileUtils.mkdir_p(memory_dir)
87
+ File.write(memory_path, YAML.dump(@store))
88
+ rescue StandardError
89
+ nil
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module Memory
5
+ # In-memory sliding window of recent tool calls and observations.
6
+ # Cleared at the end of each run. Never persisted.
7
+ class ShortTerm
8
+ DEFAULT_MAX_ENTRIES = 20
9
+
10
+ Entry = Data.define(:type, :content, :ts)
11
+
12
+ attr_reader :entries
13
+
14
+ def initialize(max: DEFAULT_MAX_ENTRIES)
15
+ @max = max
16
+ @entries = []
17
+ end
18
+
19
+ # Record a new entry.
20
+ # @param type [Symbol] :tool_call, :tool_result, :observation, :reasoning
21
+ # @param content [Object] anything serialisable
22
+ def record(type, content)
23
+ @entries << Entry.new(type: type.to_sym, content: content, ts: Time.now.to_f)
24
+ @entries.shift if @entries.size > @max
25
+ nil
26
+ end
27
+
28
+ # Return the N most recent entries.
29
+ def recent(n = 5)
30
+ @entries.last(n)
31
+ end
32
+
33
+ # Return all entries of a given type.
34
+ def by_type(type)
35
+ @entries.select { |e| e.type == type.to_sym }
36
+ end
37
+
38
+ # Last N tool calls (name + args)
39
+ def recent_tool_calls(n = 5)
40
+ by_type(:tool_call).last(n)
41
+ end
42
+
43
+ # True if the same tool+args was called recently (loop hint).
44
+ def recently_called?(tool_name, args, window: 6)
45
+ recent(window).any? do |e|
46
+ e.type == :tool_call &&
47
+ e.content[:tool] == tool_name.to_s &&
48
+ e.content[:args].to_s == args.to_s
49
+ end
50
+ end
51
+
52
+ def size
53
+ @entries.size
54
+ end
55
+
56
+ def clear!
57
+ @entries.clear
58
+ nil
59
+ end
60
+
61
+ def to_a
62
+ @entries.map(&:to_h)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module OllamaAgent
8
+ # Fetches the public Ollama cloud model catalog (+/api/tags+ on ollama.com).
9
+ #
10
+ # See https://docs.ollama.com/cloud — list models with +curl https://ollama.com/api/tags+.
11
+ # Optional +OLLAMA_API_KEY+ is sent as +Authorization: Bearer+ when set.
12
+ module OllamaCloudCatalog
13
+ DEFAULT_TAGS_URL = "https://ollama.com/api/tags"
14
+
15
+ module_function
16
+
17
+ # @param base_url [String, nil] override catalog host (default +ollama.com+ or +OLLAMA_AGENT_CLOUD_CATALOG_URL+)
18
+ # @param api_key [String, nil] Bearer token (default +ENV["OLLAMA_API_KEY"]+)
19
+ # @return [Array<String>] sorted unique model names, or empty on failure
20
+ def list_model_names(base_url: nil, api_key: nil, open_timeout: 5, read_timeout: 20)
21
+ uri = catalog_uri(base_url)
22
+ return [] unless uri
23
+
24
+ key = api_key_string(api_key)
25
+ res = http_get(uri, api_key: key, open_timeout: open_timeout, read_timeout: read_timeout)
26
+ return [] unless res.is_a?(Net::HTTPSuccess)
27
+
28
+ names_from_tags_json(res.body)
29
+ rescue StandardError
30
+ []
31
+ end
32
+
33
+ # @param body [String] raw JSON from +/api/tags+
34
+ # @return [Array<String>]
35
+ def names_from_tags_json(body)
36
+ parsed = JSON.parse(body.to_s)
37
+ models = parsed["models"] || []
38
+ models.filter_map { |m| m["name"] }.uniq.sort
39
+ end
40
+
41
+ def catalog_uri(base_url)
42
+ raw = base_url || ENV.fetch("OLLAMA_AGENT_CLOUD_CATALOG_URL", nil)
43
+ raw = DEFAULT_TAGS_URL if raw.nil? || raw.to_s.strip.empty?
44
+ URI(raw.to_s.strip)
45
+ rescue URI::InvalidURIError
46
+ nil
47
+ end
48
+
49
+ def api_key_string(api_key)
50
+ k = api_key || ENV.fetch("OLLAMA_API_KEY", nil)
51
+ k.to_s.strip
52
+ end
53
+
54
+ def http_get(uri, api_key:, open_timeout:, read_timeout:)
55
+ http = Net::HTTP.new(uri.host, uri.port)
56
+ http.use_ssl = uri.scheme == "https"
57
+ http.open_timeout = open_timeout
58
+ http.read_timeout = read_timeout
59
+
60
+ req = Net::HTTP::Get.new(uri)
61
+ req["Authorization"] = "Bearer #{api_key}" unless api_key.empty?
62
+
63
+ http.request(req)
64
+ end
65
+ end
66
+ end
@@ -15,5 +15,35 @@ module OllamaAgent
15
15
  model = ModelEnv.resolved_model_from_env
16
16
  config.model = model if model
17
17
  end
18
+
19
+ # Builds an +Ollama::Client+ wrapped in {Resilience::RetryMiddleware}.
20
+ #
21
+ # @param timeout [Numeric] read/open timeout seconds
22
+ # @param max_attempts [Integer] retry attempts for the middleware
23
+ # @param base_url [String, nil] optional explicit API base URL (e.g. from +OLLAMA_HOST+); +nil+ keeps config default
24
+ # @param hooks [Streaming::Hooks, nil] optional hooks for retry events
25
+ # @param base_delay [Float, nil] backoff base; default from {Resilience::RetryMiddleware}
26
+ # @return [Resilience::RetryMiddleware]
27
+ def self.retry_wrapped_client(timeout:, max_attempts:, base_url: nil, hooks: nil, base_delay: nil)
28
+ require "ollama_client"
29
+ require_relative "resilience/retry_middleware"
30
+
31
+ inner = Ollama::Client.new(config: config_for_client(base_url: base_url, timeout: timeout))
32
+ Resilience::RetryMiddleware.new(
33
+ client: inner,
34
+ max_attempts: max_attempts,
35
+ hooks: hooks,
36
+ base_delay: base_delay || Resilience::RetryMiddleware::DEFAULT_BASE_DELAY
37
+ )
38
+ end
39
+
40
+ def self.config_for_client(base_url:, timeout:)
41
+ config = Ollama::Config.new
42
+ config.base_url = base_url if base_url && !base_url.to_s.strip.empty?
43
+ config.timeout = timeout
44
+ apply_env_to_config(config)
45
+ config
46
+ end
47
+ private_class_method :config_for_client
18
48
  end
19
49
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry"
4
+
5
+ module OllamaAgent
6
+ module Plugins
7
+ # Loads plugins from:
8
+ # 1. OLLAMA_AGENT_PLUGINS env var (comma-separated require paths)
9
+ # 2. ~/.config/ollama_agent/plugins.rb (user global plugins)
10
+ # 3. <project_root>/.ollama_agent/plugins.rb (project-local plugins)
11
+ # 4. Gem-installed plugins with name matching "ollama_agent_*"
12
+ #
13
+ # Each loaded file is expected to call:
14
+ # OllamaAgent::Plugins::Registry.register(:my_plugin) { |r| ... }
15
+ class Loader
16
+ GLOBAL_PLUGINS_PATH = File.join(Dir.home, ".config", "ollama_agent", "plugins.rb")
17
+ GEM_PLUGIN_PREFIX = "ollama_agent_"
18
+
19
+ def initialize(root: Dir.pwd, registry: nil)
20
+ @root = File.expand_path(root)
21
+ @registry = registry || Registry.instance
22
+ end
23
+
24
+ # Load all applicable plugins.
25
+ # @param skip_gems [Boolean] skip gem-installed plugins (useful in tests)
26
+ # @return [Array<String>] paths/names of successfully loaded plugins
27
+ def load_all(skip_gems: false)
28
+ loaded = []
29
+
30
+ loaded.concat(load_env_plugins)
31
+ loaded.concat(load_file_plugin(GLOBAL_PLUGINS_PATH))
32
+ loaded.concat(load_file_plugin(project_plugins_path))
33
+ loaded.concat(load_gem_plugins) unless skip_gems
34
+
35
+ loaded
36
+ end
37
+
38
+ # Load a single plugin from a require path or file.
39
+ def load_plugin(path_or_require)
40
+ if File.exist?(path_or_require)
41
+ require File.expand_path(path_or_require)
42
+ else
43
+ require path_or_require
44
+ end
45
+ [path_or_require]
46
+ rescue LoadError => e
47
+ warn "ollama_agent: plugin load failed (#{path_or_require}): #{e.message}" if debug?
48
+ []
49
+ rescue StandardError => e
50
+ warn "ollama_agent: plugin error (#{path_or_require}): #{e.message}"
51
+ []
52
+ end
53
+
54
+ private
55
+
56
+ def load_env_plugins
57
+ env_val = ENV.fetch("OLLAMA_AGENT_PLUGINS", "").strip
58
+ return [] if env_val.empty?
59
+
60
+ env_val.split(",").flat_map { |p| load_plugin(p.strip) }
61
+ end
62
+
63
+ def load_file_plugin(path)
64
+ return [] unless File.exist?(path)
65
+
66
+ load_plugin(path)
67
+ end
68
+
69
+ def load_gem_plugins
70
+ return [] unless defined?(Gem)
71
+
72
+ loaded = []
73
+ Gem::Specification.each do |spec|
74
+ next unless spec.name.start_with?(GEM_PLUGIN_PREFIX)
75
+
76
+ plugin_file = File.join(spec.gem_dir, "lib", "#{spec.name}.rb")
77
+ next unless File.exist?(plugin_file)
78
+
79
+ loaded.concat(load_plugin(plugin_file))
80
+ end
81
+ loaded
82
+ rescue StandardError
83
+ []
84
+ end
85
+
86
+ def project_plugins_path
87
+ File.join(@root, ".ollama_agent", "plugins.rb")
88
+ end
89
+
90
+ def debug?
91
+ ENV["OLLAMA_AGENT_DEBUG"] == "1"
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module Plugins
5
+ # Central plugin registry.
6
+ #
7
+ # A plugin is any object (module, class, or struct) that can register
8
+ # extensions at one or more extension points.
9
+ #
10
+ # Extension points:
11
+ # :tools — Array<Tools::Base subclass instances>
12
+ # :prompts — Array<Hash { name:, content: }>
13
+ # :policies — Array<Proc(tool, args, ctx) → nil | String>
14
+ # :providers — Array<Providers::Base instances>
15
+ # :postprocessors — Array<Proc(response) → response>
16
+ # :memory_adapters — Array<Memory::Base-like objects>
17
+ # :command_handlers — Array<Hash { slash_command:, handler: Proc }>
18
+ #
19
+ # @example Register a plugin inline
20
+ # OllamaAgent::Plugins::Registry.register(:my_plugin) do |r|
21
+ # r.extend(:tools, MyTool.new)
22
+ # r.extend(:prompts, name: "code_review", content: File.read("prompts/review.md"))
23
+ # end
24
+ class Registry
25
+ EXTENSION_POINTS = %i[
26
+ tools prompts policies providers
27
+ postprocessors memory_adapters command_handlers
28
+ ].freeze
29
+
30
+ class << self
31
+ def instance
32
+ @instance ||= new
33
+ end
34
+
35
+ # Delegate class methods to the singleton.
36
+ def register(name, plugin = nil, &block) = instance.register(name, plugin, &block)
37
+ def extensions_for(point) = instance.extensions_for(point)
38
+ def all_tools = instance.extensions_for(:tools)
39
+ def all_prompts = instance.extensions_for(:prompts)
40
+ def all_policies = instance.extensions_for(:policies)
41
+ def all_providers = instance.extensions_for(:providers)
42
+ def all_postprocessors = instance.extensions_for(:postprocessors)
43
+ def all_command_handlers = instance.extensions_for(:command_handlers)
44
+ def plugin_names = instance.plugin_names
45
+ def reset! = instance.reset!
46
+ end
47
+
48
+ def initialize
49
+ reset!
50
+ end
51
+
52
+ # Register a plugin by name.
53
+ # @param name [Symbol, String] unique plugin identifier
54
+ # @param plugin [Object, nil] plugin object (must respond to #register(registry))
55
+ # @param block [Proc] alternative: inline registration block
56
+ def register(name, plugin = nil, &block)
57
+ name = name.to_sym
58
+ raise ArgumentError, "Plugin #{name} is already registered" if @plugins.key?(name)
59
+
60
+ @plugins[name] = plugin
61
+
62
+ if block
63
+ block.call(self)
64
+ elsif plugin.respond_to?(:register)
65
+ plugin.register(self)
66
+ end
67
+ end
68
+
69
+ # Add an extension at a specific extension point.
70
+ # @param point [Symbol] one of EXTENSION_POINTS
71
+ # @param handler [Object] see per-point documentation above
72
+ def extend(point, handler)
73
+ point = point.to_sym
74
+ unless EXTENSION_POINTS.include?(point)
75
+ raise ArgumentError,
76
+ "Unknown extension point: #{point}. Valid: #{EXTENSION_POINTS.join(", ")}"
77
+ end
78
+
79
+ @extensions[point] << handler
80
+ end
81
+
82
+ # Shorthand helpers
83
+ def add_tool(tool) = extend(:tools, tool)
84
+ def add_prompt(hash) = extend(:prompts, hash)
85
+ def add_policy(&block) = extend(:policies, block)
86
+ def add_provider(provider) = extend(:providers, provider)
87
+ def add_command(slash_command:, &handler) = extend(:command_handlers, { slash_command: slash_command, handler: handler })
88
+
89
+ def extensions_for(point)
90
+ @extensions[point.to_sym].dup
91
+ end
92
+
93
+ def plugin_names
94
+ @plugins.keys
95
+ end
96
+
97
+ def reset!
98
+ @plugins = {}
99
+ @extensions = Hash.new { |h, k| h[k] = [] }
100
+ end
101
+ end
102
+ end
103
+ end