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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/skills/ruby-code-review-levels/SKILL.md +115 -0
  3. data/.cursor/skills/self-improvement-sandbox-safety/SKILL.md +65 -0
  4. data/.env.example +25 -0
  5. data/CHANGELOG.md +40 -0
  6. data/README.md +135 -4
  7. data/docs/ARCHITECTURE.md +42 -0
  8. data/docs/PERFORMANCE.md +22 -0
  9. data/docs/SESSIONS.md +48 -0
  10. data/docs/TOOLS.md +53 -0
  11. data/docs/TOOL_RUNTIME.md +154 -0
  12. data/docs/superpowers/plans/2026-03-26-production-ready-ollama-agent.md +2454 -0
  13. data/docs/superpowers/specs/2026-03-26-production-ready-ollama-agent-design.md +400 -0
  14. data/lib/ollama_agent/agent/agent_config.rb +53 -0
  15. data/lib/ollama_agent/agent/client_wiring.rb +76 -0
  16. data/lib/ollama_agent/agent/prompt_wiring.rb +55 -0
  17. data/lib/ollama_agent/agent/session_wiring.rb +53 -0
  18. data/lib/ollama_agent/agent.rb +148 -73
  19. data/lib/ollama_agent/agent_prompt.rb +31 -1
  20. data/lib/ollama_agent/chat_stream_carry.rb +88 -0
  21. data/lib/ollama_agent/chat_stream_thinking_format.rb +29 -0
  22. data/lib/ollama_agent/cli.rb +394 -4
  23. data/lib/ollama_agent/console.rb +177 -5
  24. data/lib/ollama_agent/context/manager.rb +100 -0
  25. data/lib/ollama_agent/context/token_counter.rb +33 -0
  26. data/lib/ollama_agent/diff_path_validator.rb +32 -10
  27. data/lib/ollama_agent/env_config.rb +44 -0
  28. data/lib/ollama_agent/external_agents/TODO-plan.md +1948 -0
  29. data/lib/ollama_agent/external_agents/argv_interp.rb +21 -0
  30. data/lib/ollama_agent/external_agents/default_agents.yml +60 -0
  31. data/lib/ollama_agent/external_agents/delegate_logger.rb +31 -0
  32. data/lib/ollama_agent/external_agents/delegate_timeout_status.rb +12 -0
  33. data/lib/ollama_agent/external_agents/env_helpers.rb +38 -0
  34. data/lib/ollama_agent/external_agents/path_validator.rb +32 -0
  35. data/lib/ollama_agent/external_agents/probe.rb +122 -0
  36. data/lib/ollama_agent/external_agents/registry.rb +50 -0
  37. data/lib/ollama_agent/external_agents/runner.rb +118 -0
  38. data/lib/ollama_agent/external_agents.rb +9 -0
  39. data/lib/ollama_agent/global_dotenv.rb +39 -0
  40. data/lib/ollama_agent/model_env.rb +26 -0
  41. data/lib/ollama_agent/ollama_chat_thinking_stream.rb +41 -0
  42. data/lib/ollama_agent/ollama_connection.rb +6 -1
  43. data/lib/ollama_agent/patch_risk.rb +81 -0
  44. data/lib/ollama_agent/patch_support.rb +27 -1
  45. data/lib/ollama_agent/path_sandbox.rb +62 -0
  46. data/lib/ollama_agent/prompt_skills/clean_ruby.md +131 -0
  47. data/lib/ollama_agent/prompt_skills/code_review.md +112 -0
  48. data/lib/ollama_agent/prompt_skills/design_patterns.md +56 -0
  49. data/lib/ollama_agent/prompt_skills/manifest.yml +25 -0
  50. data/lib/ollama_agent/prompt_skills/ollama_agent_patterns.md +132 -0
  51. data/lib/ollama_agent/prompt_skills/rails_best_practices.md +41 -0
  52. data/lib/ollama_agent/prompt_skills/rails_style.md +138 -0
  53. data/lib/ollama_agent/prompt_skills/rspec.md +280 -0
  54. data/lib/ollama_agent/prompt_skills/rubocop.md +7 -0
  55. data/lib/ollama_agent/prompt_skills/ruby_style.md +121 -0
  56. data/lib/ollama_agent/prompt_skills/solid.md +270 -0
  57. data/lib/ollama_agent/prompt_skills/solid_ruby.md +223 -0
  58. data/lib/ollama_agent/prompt_skills.rb +169 -0
  59. data/lib/ollama_agent/repo_list.rb +4 -1
  60. data/lib/ollama_agent/resilience/audit_logger.rb +79 -0
  61. data/lib/ollama_agent/resilience/retry_middleware.rb +45 -0
  62. data/lib/ollama_agent/resilience/retry_policy.rb +51 -0
  63. data/lib/ollama_agent/ruby_index_tool_support.rb +17 -6
  64. data/lib/ollama_agent/runner.rb +123 -0
  65. data/lib/ollama_agent/sandboxed_tools/delegate_external.rb +62 -0
  66. data/lib/ollama_agent/sandboxed_tools/file_read_write.rb +100 -0
  67. data/lib/ollama_agent/sandboxed_tools/search_text.rb +60 -0
  68. data/lib/ollama_agent/sandboxed_tools.rb +55 -116
  69. data/lib/ollama_agent/search_backend.rb +93 -0
  70. data/lib/ollama_agent/self_improvement/analyzer.rb +34 -0
  71. data/lib/ollama_agent/self_improvement/improver.rb +340 -0
  72. data/lib/ollama_agent/self_improvement/modes.rb +25 -0
  73. data/lib/ollama_agent/self_improvement/ruby_mastery_context.rb +66 -0
  74. data/lib/ollama_agent/self_improvement.rb +5 -0
  75. data/lib/ollama_agent/session/session.rb +8 -0
  76. data/lib/ollama_agent/session/store.rb +68 -0
  77. data/lib/ollama_agent/streaming/console_streamer.rb +29 -0
  78. data/lib/ollama_agent/streaming/hooks.rb +39 -0
  79. data/lib/ollama_agent/tool_arguments.rb +13 -1
  80. data/lib/ollama_agent/tool_content_parser.rb +1 -1
  81. data/lib/ollama_agent/tool_runtime/executor.rb +34 -0
  82. data/lib/ollama_agent/tool_runtime/json_extractor.rb +62 -0
  83. data/lib/ollama_agent/tool_runtime/loop.rb +72 -0
  84. data/lib/ollama_agent/tool_runtime/memory.rb +32 -0
  85. data/lib/ollama_agent/tool_runtime/ollama_json_planner.rb +98 -0
  86. data/lib/ollama_agent/tool_runtime/plan_extractor.rb +12 -0
  87. data/lib/ollama_agent/tool_runtime/registry.rb +60 -0
  88. data/lib/ollama_agent/tool_runtime/tool.rb +24 -0
  89. data/lib/ollama_agent/tool_runtime.rb +24 -0
  90. data/lib/ollama_agent/tools/registry.rb +55 -0
  91. data/lib/ollama_agent/tools_schema.rb +74 -1
  92. data/lib/ollama_agent/user_prompt.rb +35 -0
  93. data/lib/ollama_agent/version.rb +1 -1
  94. data/lib/ollama_agent.rb +25 -0
  95. data/reproduce_429.rb +40 -0
  96. data/sig/ollama_agent.rbs +111 -1
  97. metadata +78 -2
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OllamaAgent
6
+ module ToolRuntime
7
+ # Pulls the first top-level JSON object from text using brace matching (strings respected).
8
+ module JsonExtractor
9
+ class << self
10
+ def extract_object(text)
11
+ raise ArgumentError, "text must be a String" unless text.is_a?(String)
12
+
13
+ start_idx = text.index("{")
14
+ raise JsonParseError, "no JSON object found" if start_idx.nil?
15
+
16
+ slice = extract_balanced_object(text, start_idx)
17
+ JSON.parse(slice)
18
+ rescue JSON::ParserError => e
19
+ raise JsonParseError, "invalid JSON: #{e.message}"
20
+ end
21
+
22
+ private
23
+
24
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength -- brace scanner with string/escape states
25
+ def extract_balanced_object(str, start_idx)
26
+ depth = 0
27
+ i = start_idx
28
+ in_string = false
29
+ escape = false
30
+
31
+ while i < str.length
32
+ c = str[i]
33
+ if escape
34
+ escape = false
35
+ elsif in_string
36
+ case c
37
+ when "\\"
38
+ escape = true
39
+ when '"'
40
+ in_string = false
41
+ end
42
+ else
43
+ case c
44
+ when '"'
45
+ in_string = true
46
+ when "{"
47
+ depth += 1
48
+ when "}"
49
+ depth -= 1
50
+ return str[start_idx..i] if depth.zero?
51
+ end
52
+ end
53
+ i += 1
54
+ end
55
+
56
+ raise JsonParseError, "unbalanced braces in JSON object"
57
+ end
58
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module ToolRuntime
5
+ # Think → resolve tool → execute → observe (memory); stops when a tool returns `status: "done"` or max_steps.
6
+ # {#run} returns the last tool result (the object returned from {Executor#execute} on the final step).
7
+ class Loop
8
+ attr_reader :max_steps, :plan_extractor
9
+
10
+ alias planner plan_extractor
11
+
12
+ # rubocop:disable Metrics/ParameterLists -- runtime wiring matches plan (planner, registry, executor, memory, logger)
13
+ def initialize(registry:, executor:, memory:, logger: nil, max_steps: 10, plan_extractor: nil, planner: nil)
14
+ @plan_extractor = plan_extractor || planner
15
+ raise ArgumentError, "plan_extractor or planner is required" if @plan_extractor.nil?
16
+
17
+ @registry = registry
18
+ @executor = executor
19
+ @memory = memory
20
+ @logger = logger
21
+ @max_steps = Integer(max_steps)
22
+ end
23
+ # rubocop:enable Metrics/ParameterLists
24
+
25
+ def run(context:)
26
+ steps = 0
27
+ final_result = nil
28
+ Kernel.loop do
29
+ raise MaxStepsExceeded, "max_steps=#{@max_steps} exceeded" if steps >= @max_steps
30
+
31
+ thought, action, final_result = plan_and_execute(context)
32
+ record_step(thought: thought, action: action, result: final_result)
33
+ break if terminated?(final_result)
34
+
35
+ steps += 1
36
+ end
37
+ final_result
38
+ end
39
+
40
+ private
41
+
42
+ def plan_and_execute(context)
43
+ thought = @plan_extractor.next_step(context: context, memory: @memory, registry: @registry)
44
+ action = @registry.resolve(thought)
45
+ raise InvalidPlanError, "invalid tool plan: #{thought.inspect}" unless action
46
+
47
+ result = @executor.execute(action)
48
+ [thought, action, result]
49
+ end
50
+
51
+ def record_step(thought:, action:, result:)
52
+ log_step(thought: thought, action: action, result: result)
53
+ @memory.append(thought: thought, action: action, result: result)
54
+ end
55
+
56
+ def terminated?(result)
57
+ return false unless result.is_a?(Hash)
58
+
59
+ status = result["status"] || result[:status]
60
+ status.to_s == "done"
61
+ end
62
+
63
+ def log_step(thought:, action:, result:)
64
+ return unless @logger.respond_to?(:info)
65
+
66
+ tool = action[:tool]
67
+ tool_name = tool.respond_to?(:name) ? tool.name : "?"
68
+ @logger.info("tool_runtime thought=#{thought.inspect} tool=#{tool_name} result=#{result.inspect}")
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module ToolRuntime
5
+ # Short-term transcript of planner output, resolved actions, and tool results.
6
+ class Memory
7
+ # @return [String, nil] optional text injected into planner prompts (e.g. registry descriptions)
8
+ attr_accessor :tool_descriptions
9
+
10
+ def initialize(limit: 10)
11
+ @steps = []
12
+ @limit = [Integer(limit), 1].max
13
+ @tool_descriptions = nil
14
+ end
15
+
16
+ def append(thought:, action:, result:)
17
+ @steps << { thought: thought, action: action, result: result }
18
+ @steps.shift while @steps.size > @limit
19
+ end
20
+
21
+ def recent(last_n = nil)
22
+ return @steps.dup if last_n.nil?
23
+
24
+ @steps.last(Integer(last_n))
25
+ end
26
+
27
+ def tool_descriptions_for_prompt
28
+ @tool_descriptions.to_s
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ollama_client"
5
+ require_relative "../model_env"
6
+ require_relative "plan_extractor"
7
+
8
+ module OllamaAgent
9
+ module ToolRuntime
10
+ # Asks an Ollama chat model for the next tool call as a single JSON object.
11
+ class OllamaJsonPlanner
12
+ include PlanExtractor
13
+
14
+ # @param model [String, nil] when nil or blank, uses {OllamaAgent::ModelEnv.default_chat_model}
15
+ def initialize(client:, model: nil, chat_options: nil)
16
+ @client = client
17
+ @model = resolve_model(model)
18
+ @chat_options = chat_options || { temperature: 0.2 }
19
+ end
20
+
21
+ def next_step(context:, memory:, registry:)
22
+ prompt = build_prompt(context: context, memory: memory, registry: registry)
23
+ response = @client.chat(
24
+ model: @model,
25
+ messages: [{ role: "user", content: prompt }],
26
+ options: @chat_options
27
+ )
28
+ content = assistant_content(response)
29
+ JsonExtractor.extract_object(content)
30
+ end
31
+
32
+ private
33
+
34
+ def resolve_model(explicit)
35
+ s = explicit.to_s.strip
36
+ return s unless s.empty?
37
+
38
+ ModelEnv.default_chat_model
39
+ end
40
+
41
+ def build_prompt(context:, memory:, registry:)
42
+ ctx = context.is_a?(String) ? context : JSON.generate(context)
43
+ tools_block = merged_tool_descriptions(memory, registry)
44
+ <<~PROMPT.strip
45
+ You are an agent step planner. Reply with exactly one JSON object and no other text.
46
+ Keys: "tool" (string, required) and "args" (object, optional, default {}).
47
+ Use only tool names listed under Available tools.
48
+
49
+ Available tools:
50
+ #{tools_block}
51
+
52
+ Context:
53
+ #{ctx}
54
+
55
+ Prior steps (JSON):
56
+ #{memory_json(memory)}
57
+ PROMPT
58
+ end
59
+
60
+ def merged_tool_descriptions(memory, registry)
61
+ base = registry.descriptions_for_prompt
62
+ extra = memory.tool_descriptions_for_prompt
63
+ extra.empty? ? base : "#{extra}\n#{base}"
64
+ end
65
+
66
+ def memory_json(memory)
67
+ JSON.generate(memory.recent.map { |step| memory_step_row(step) })
68
+ end
69
+
70
+ def memory_step_row(step)
71
+ row = { "thought" => step[:thought], "result" => step[:result] }
72
+ attach_action_to_row(row, step[:action])
73
+ row
74
+ end
75
+
76
+ def attach_action_to_row(row, action)
77
+ return unless action.is_a?(Hash)
78
+
79
+ tool = action[:tool]
80
+ row["tool"] = tool.respond_to?(:name) ? tool.name : nil
81
+ row["args"] = action[:args]
82
+ end
83
+
84
+ def assistant_content(response)
85
+ msg = response.message
86
+ return "" if msg.nil?
87
+
88
+ if msg.respond_to?(:content)
89
+ c = msg.content
90
+ return c.to_s unless c.nil?
91
+ end
92
+ return msg["content"].to_s if msg.is_a?(Hash)
93
+
94
+ msg.to_s
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module ToolRuntime
5
+ # Strategy protocol for {Loop}: produce the next plan hash from context and memory.
6
+ #
7
+ # @example Contract
8
+ # def next_step(context:, memory:, registry:) -> Hash
9
+ module PlanExtractor
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OllamaAgent
6
+ module ToolRuntime
7
+ # Per-run tool lookup and prompt text for available tools.
8
+ class Registry
9
+ def initialize(tools = [])
10
+ @tools = {}
11
+ Array(tools).each { |tool| register(tool) }
12
+ end
13
+
14
+ def register(tool)
15
+ raise ArgumentError, "tool must respond to #name" unless tool.respond_to?(:name)
16
+
17
+ key = tool.name.to_s
18
+ raise ArgumentError, "duplicate tool name: #{key}" if @tools.key?(key)
19
+
20
+ @tools[key] = tool
21
+ end
22
+
23
+ # @param plan [Hash] must include "tool" (or :tool); optional "args" / :args
24
+ # @return [Hash, nil] `{ tool: Tool instance, args: Hash }` or nil if unknown
25
+ def resolve(plan)
26
+ return nil unless plan.is_a?(Hash)
27
+
28
+ tool_name = tool_name_from(plan)
29
+ return nil if tool_name.nil? || tool_name.to_s.strip.empty?
30
+
31
+ tool = @tools[tool_name.to_s]
32
+ return nil unless tool
33
+
34
+ { tool: tool, args: normalize_args(plan) }
35
+ end
36
+
37
+ def descriptions_for_prompt
38
+ @tools.values.map do |t|
39
+ "#{t.name}: #{t.description} schema=#{JSON.generate(t.schema)}"
40
+ end.join("\n")
41
+ end
42
+
43
+ private
44
+
45
+ def tool_name_from(plan)
46
+ plan["tool"] || plan[:tool]
47
+ end
48
+
49
+ def normalize_args(plan)
50
+ args = plan["args"] || plan[:args] || {}
51
+ args = {} unless args.is_a?(Hash)
52
+ stringify_keys(args)
53
+ end
54
+
55
+ def stringify_keys(hash)
56
+ hash.transform_keys(&:to_s)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ module ToolRuntime
5
+ # Plugin contract for tools used with {Registry}, {Executor}, and {Loop}.
6
+ class Tool
7
+ def name
8
+ raise NotImplementedError, "#{self.class} must implement #name"
9
+ end
10
+
11
+ def description
12
+ raise NotImplementedError, "#{self.class} must implement #description"
13
+ end
14
+
15
+ def schema
16
+ raise NotImplementedError, "#{self.class} must implement #schema"
17
+ end
18
+
19
+ def call(args)
20
+ raise NotImplementedError, "#{self.class} must implement #call"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generic think → act → observe loop for JSON-shaped tool plans (plugin tools).
4
+ # Distinct from {OllamaAgent::Agent}, which uses Ollama native /api/chat tool_calls for coding.
5
+ module OllamaAgent
6
+ module ToolRuntime
7
+ class Error < OllamaAgent::Error; end
8
+
9
+ class JsonParseError < Error; end
10
+
11
+ class InvalidPlanError < Error; end
12
+
13
+ class MaxStepsExceeded < Error; end
14
+ end
15
+ end
16
+
17
+ require_relative "tool_runtime/tool"
18
+ require_relative "tool_runtime/memory"
19
+ require_relative "tool_runtime/registry"
20
+ require_relative "tool_runtime/executor"
21
+ require_relative "tool_runtime/json_extractor"
22
+ require_relative "tool_runtime/plan_extractor"
23
+ require_relative "tool_runtime/ollama_json_planner"
24
+ require_relative "tool_runtime/loop"
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ # Provides tool registration and execution helpers for OllamaAgent.
5
+ module Tools
6
+ # Delegate class-methods so consumers call OllamaAgent::Tools.register(...)
7
+ def self.register(name, schema:, &) = Registry.register(name, schema: schema, &)
8
+ def self.custom_tool?(name) = Registry.custom_tool?(name)
9
+
10
+ def self.execute_custom(name, args, root:, read_only:)
11
+ Registry.execute_custom(name, args, root: root, read_only: read_only)
12
+ end
13
+
14
+ def self.custom_schemas = Registry.custom_schemas
15
+ def self.reset! = Registry.reset!
16
+
17
+ # Stores and executes custom tool definitions registered by users.
18
+ module Registry
19
+ @custom_tools = {}
20
+
21
+ class << self
22
+ def register(name, schema:, &handler)
23
+ raise ArgumentError, "handler block required" unless block_given?
24
+ raise ArgumentError, "schema must be a Hash" unless schema.is_a?(Hash)
25
+
26
+ @custom_tools[name.to_s] = { schema: schema, handler: handler }
27
+ end
28
+
29
+ def custom_tool?(name)
30
+ @custom_tools.key?(name.to_s)
31
+ end
32
+
33
+ def execute_custom(name, args, root:, read_only:)
34
+ entry = @custom_tools[name.to_s]
35
+ return "Unknown custom tool: #{name}" unless entry
36
+
37
+ entry[:handler].call(args, root: root, read_only: read_only)
38
+ end
39
+
40
+ def custom_schemas
41
+ @custom_tools.map do |name, entry|
42
+ {
43
+ type: "function",
44
+ function: entry[:schema].merge(name: name)
45
+ }
46
+ end
47
+ end
48
+
49
+ def reset!
50
+ @custom_tools = {}
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module OllamaAgent
3
+ # Tool JSON schemas for Ollama /api/chat (base + optional orchestrator tools).
4
+ module OllamaAgent # rubocop:disable Metrics/ModuleLength -- schema tables; splitting would scatter definitions
4
5
  # JSON tool definitions passed to Ollama /api/chat.
5
6
  TOOLS = [
6
7
  {
@@ -73,6 +74,78 @@ module OllamaAgent
73
74
  required: %w[path diff]
74
75
  }
75
76
  }
77
+ },
78
+ {
79
+ type: "function",
80
+ function: {
81
+ name: "write_file",
82
+ description: "Create or overwrite a file under the project root with full UTF-8 content. " \
83
+ "Use for new files or complete rewrites. Prefer edit_file for surgical changes.",
84
+ parameters: {
85
+ type: "object",
86
+ properties: {
87
+ path: { type: "string", description: "File path relative to project root" },
88
+ content: { type: "string", description: "Full file content to write" }
89
+ },
90
+ required: %w[path content]
91
+ }
92
+ }
76
93
  }
77
94
  ].freeze
95
+
96
+ READ_ONLY_TOOLS = TOOLS.reject { |t| %w[edit_file write_file].include?(t.dig(:function, :name)) }.freeze
97
+
98
+ ORCHESTRATOR_LIST_TOOL = {
99
+ type: "function",
100
+ function: {
101
+ name: "list_external_agents",
102
+ description: "List configured external CLI agents (Claude, Gemini, Codex, Cursor, etc.): availability, " \
103
+ "path, version, capabilities. Call before delegate_to_agent to choose an agent_id.",
104
+ parameters: {
105
+ type: "object",
106
+ properties: {},
107
+ required: []
108
+ }
109
+ }
110
+ }.freeze
111
+
112
+ ORCHESTRATOR_DELEGATE_TOOL = {
113
+ type: "function",
114
+ function: {
115
+ name: "delegate_to_agent",
116
+ description: "Run a task via an external CLI agent (non-interactive argv only). Use after " \
117
+ "list_external_agents; pass a concise task and context_summary; prefer exploring the repo " \
118
+ "with read_file/search_code first to save tokens.",
119
+ parameters: {
120
+ type: "object",
121
+ properties: {
122
+ agent_id: { type: "string", description: "Registry id (e.g. claude_cli, gemini_cli)" },
123
+ task: { type: "string", description: "What the external agent should do" },
124
+ context_summary: { type: "string", description: "Short context from your own exploration" },
125
+ paths: {
126
+ type: "array",
127
+ items: { type: "string" },
128
+ description: "Optional relative paths under project root to mention in the handoff"
129
+ },
130
+ timeout_seconds: {
131
+ type: "integer",
132
+ description: "Optional timeout (defaults from registry or 600)"
133
+ }
134
+ },
135
+ required: %w[agent_id task]
136
+ }
137
+ }
138
+ }.freeze
139
+
140
+ ORCHESTRATOR_TOOLS = [ORCHESTRATOR_LIST_TOOL, ORCHESTRATOR_DELEGATE_TOOL].freeze
141
+ ORCHESTRATOR_READ_ONLY_TOOLS = [ORCHESTRATOR_LIST_TOOL].freeze
142
+ ORCHESTRATOR_TOOLS_SCHEMA_VERSION = "1"
143
+
144
+ def self.tools_for(read_only:, orchestrator:)
145
+ base = read_only ? READ_ONLY_TOOLS : TOOLS
146
+ base += OllamaAgent::Tools::Registry.custom_schemas
147
+ return base unless orchestrator
148
+
149
+ base + (read_only ? ORCHESTRATOR_READ_ONLY_TOOLS : ORCHESTRATOR_TOOLS)
150
+ end
78
151
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OllamaAgent
4
+ # Injectable stdin/stdout for patch/write/delegate confirmations (tests use StringIO).
5
+ # rubocop:disable Naming/PredicateMethod -- confirm_* are interactive prompts returning bool
6
+ class UserPrompt
7
+ def initialize(stdin: $stdin, stdout: $stdout)
8
+ @stdin = stdin
9
+ @stdout = stdout
10
+ end
11
+
12
+ def confirm_patch(path, diff)
13
+ @stdout.puts Console.patch_title("Proposed diff for #{path}:")
14
+ @stdout.puts diff
15
+ @stdout.print Console.apply_prompt("Apply? (y/n) ")
16
+ @stdin.gets.to_s.chomp.casecmp("y").zero?
17
+ end
18
+
19
+ def confirm_write_file(path, content_preview)
20
+ @stdout.puts Console.patch_title("Proposed write_file for #{path}:")
21
+ @stdout.puts content_preview
22
+ @stdout.print Console.apply_prompt("Write file? (y/n) ")
23
+ @stdin.gets.to_s.chomp.casecmp("y").zero?
24
+ end
25
+
26
+ def confirm_delegate(agent_id, task)
27
+ @stdout.puts Console.patch_title("Delegate to #{agent_id}:")
28
+ @stdout.puts task.to_s[0, 2000]
29
+ @stdout.puts "..." if task.to_s.length > 2000
30
+ @stdout.print Console.apply_prompt("Run external agent? (y/n) ")
31
+ @stdin.gets.to_s.chomp.casecmp("y").zero?
32
+ end
33
+ end
34
+ # rubocop:enable Naming/PredicateMethod
35
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OllamaAgent
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/ollama_agent.rb CHANGED
@@ -1,11 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ollama_agent/version"
4
+
5
+ env_before_ollama_client = ENV.to_hash
4
6
  require "ollama_client"
7
+ require_relative "ollama_agent/global_dotenv"
8
+ OllamaAgent::GlobalDotenv.reconcile_after_ollama_client!(env_before_ollama_client)
9
+ require_relative "ollama_agent/ollama_chat_thinking_stream"
5
10
  require_relative "ollama_agent/console"
11
+ require_relative "ollama_agent/tools/registry"
12
+ require_relative "ollama_agent/streaming/hooks"
13
+ require_relative "ollama_agent/streaming/console_streamer"
14
+ require_relative "ollama_agent/resilience/retry_middleware"
15
+ require_relative "ollama_agent/resilience/audit_logger"
16
+ require_relative "ollama_agent/context/token_counter"
17
+ require_relative "ollama_agent/context/manager"
18
+ require_relative "ollama_agent/session/session"
19
+ require_relative "ollama_agent/session/store"
6
20
  require_relative "ollama_agent/agent"
21
+ require_relative "ollama_agent/runner"
7
22
  require_relative "ollama_agent/cli"
8
23
 
24
+ # Public namespace for the Ollama-backed coding agent gem (CLI, Agent, tools, self-improvement helpers).
9
25
  module OllamaAgent
10
26
  class Error < StandardError; end
27
+
28
+ class ConfigurationError < Error; end
29
+
30
+ def self.gem_root
31
+ File.expand_path("..", __dir__)
32
+ end
11
33
  end
34
+
35
+ require_relative "ollama_agent/tool_runtime"
36
+ require_relative "ollama_agent/self_improvement"
data/reproduce_429.rb ADDED
@@ -0,0 +1,40 @@
1
+
2
+ require "ollama_client"
3
+ require_relative "lib/ollama_agent"
4
+
5
+ # Mock the client to raise Ollama::HTTPError
6
+ class MockClient
7
+ def chat(**args)
8
+ # Simulate HTTP 429 error from ollama-client
9
+ # In ollama-client 1.1.0, handle_http_error raises HTTPError with message
10
+ # status is not a separate attribute in the error itself usually,
11
+ # but let's see how it looks in the backtrace:
12
+ # "HTTP 429: you (shubhamtaywade82) have reached your weekly usage limit"
13
+ raise Ollama::HTTPError.new("HTTP 429: you (shubhamtaywade82) have reached your weekly usage limit")
14
+ end
15
+ end
16
+
17
+ # We need to mock Ollama::Config as well because Agent calls it
18
+ module Ollama
19
+ class Config
20
+ attr_accessor :model, :timeout
21
+ def initialize
22
+ @model = "llama3"
23
+ @timeout = 30
24
+ end
25
+ end
26
+ end
27
+
28
+ # Initialize agent with mock client wrapped in RetryMiddleware
29
+ mock_client = MockClient.new
30
+ retry_wrapped = OllamaAgent::Resilience::RetryMiddleware.new(client: mock_client, max_attempts: 2)
31
+ agent = OllamaAgent::Agent.new(client: retry_wrapped)
32
+
33
+ begin
34
+ agent.run("hello")
35
+ rescue => e
36
+ puts "Caught error: #{e.class}: #{e.message}"
37
+ puts "Responds to status? #{e.respond_to?(:status)}"
38
+ puts "Status: #{e.status}" if e.respond_to?(:status)
39
+ puts e.backtrace.first(5).join("\n")
40
+ end