ollama_agent 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.cursor/skills/ruby-code-review-levels/SKILL.md +115 -0
- data/.cursor/skills/self-improvement-sandbox-safety/SKILL.md +65 -0
- data/.env.example +25 -0
- data/CHANGELOG.md +40 -0
- data/README.md +135 -4
- data/docs/ARCHITECTURE.md +42 -0
- data/docs/PERFORMANCE.md +22 -0
- data/docs/SESSIONS.md +48 -0
- data/docs/TOOLS.md +53 -0
- data/docs/TOOL_RUNTIME.md +154 -0
- data/docs/superpowers/plans/2026-03-26-production-ready-ollama-agent.md +2454 -0
- data/docs/superpowers/specs/2026-03-26-production-ready-ollama-agent-design.md +400 -0
- data/lib/ollama_agent/agent/agent_config.rb +53 -0
- data/lib/ollama_agent/agent/client_wiring.rb +76 -0
- data/lib/ollama_agent/agent/prompt_wiring.rb +55 -0
- data/lib/ollama_agent/agent/session_wiring.rb +53 -0
- data/lib/ollama_agent/agent.rb +148 -73
- data/lib/ollama_agent/agent_prompt.rb +31 -1
- data/lib/ollama_agent/chat_stream_carry.rb +88 -0
- data/lib/ollama_agent/chat_stream_thinking_format.rb +29 -0
- data/lib/ollama_agent/cli.rb +394 -4
- data/lib/ollama_agent/console.rb +177 -5
- data/lib/ollama_agent/context/manager.rb +100 -0
- data/lib/ollama_agent/context/token_counter.rb +33 -0
- data/lib/ollama_agent/diff_path_validator.rb +32 -10
- data/lib/ollama_agent/env_config.rb +44 -0
- data/lib/ollama_agent/external_agents/TODO-plan.md +1948 -0
- data/lib/ollama_agent/external_agents/argv_interp.rb +21 -0
- data/lib/ollama_agent/external_agents/default_agents.yml +60 -0
- data/lib/ollama_agent/external_agents/delegate_logger.rb +31 -0
- data/lib/ollama_agent/external_agents/delegate_timeout_status.rb +12 -0
- data/lib/ollama_agent/external_agents/env_helpers.rb +38 -0
- data/lib/ollama_agent/external_agents/path_validator.rb +32 -0
- data/lib/ollama_agent/external_agents/probe.rb +122 -0
- data/lib/ollama_agent/external_agents/registry.rb +50 -0
- data/lib/ollama_agent/external_agents/runner.rb +118 -0
- data/lib/ollama_agent/external_agents.rb +9 -0
- data/lib/ollama_agent/global_dotenv.rb +39 -0
- data/lib/ollama_agent/model_env.rb +26 -0
- data/lib/ollama_agent/ollama_chat_thinking_stream.rb +41 -0
- data/lib/ollama_agent/ollama_connection.rb +6 -1
- data/lib/ollama_agent/patch_risk.rb +81 -0
- data/lib/ollama_agent/patch_support.rb +27 -1
- data/lib/ollama_agent/path_sandbox.rb +62 -0
- data/lib/ollama_agent/prompt_skills/clean_ruby.md +131 -0
- data/lib/ollama_agent/prompt_skills/code_review.md +112 -0
- data/lib/ollama_agent/prompt_skills/design_patterns.md +56 -0
- data/lib/ollama_agent/prompt_skills/manifest.yml +25 -0
- data/lib/ollama_agent/prompt_skills/ollama_agent_patterns.md +132 -0
- data/lib/ollama_agent/prompt_skills/rails_best_practices.md +41 -0
- data/lib/ollama_agent/prompt_skills/rails_style.md +138 -0
- data/lib/ollama_agent/prompt_skills/rspec.md +280 -0
- data/lib/ollama_agent/prompt_skills/rubocop.md +7 -0
- data/lib/ollama_agent/prompt_skills/ruby_style.md +121 -0
- data/lib/ollama_agent/prompt_skills/solid.md +270 -0
- data/lib/ollama_agent/prompt_skills/solid_ruby.md +223 -0
- data/lib/ollama_agent/prompt_skills.rb +169 -0
- data/lib/ollama_agent/repo_list.rb +4 -1
- data/lib/ollama_agent/resilience/audit_logger.rb +79 -0
- data/lib/ollama_agent/resilience/retry_middleware.rb +45 -0
- data/lib/ollama_agent/resilience/retry_policy.rb +51 -0
- data/lib/ollama_agent/ruby_index_tool_support.rb +17 -6
- data/lib/ollama_agent/runner.rb +123 -0
- data/lib/ollama_agent/sandboxed_tools/delegate_external.rb +62 -0
- data/lib/ollama_agent/sandboxed_tools/file_read_write.rb +100 -0
- data/lib/ollama_agent/sandboxed_tools/search_text.rb +60 -0
- data/lib/ollama_agent/sandboxed_tools.rb +55 -116
- data/lib/ollama_agent/search_backend.rb +93 -0
- data/lib/ollama_agent/self_improvement/analyzer.rb +34 -0
- data/lib/ollama_agent/self_improvement/improver.rb +340 -0
- data/lib/ollama_agent/self_improvement/modes.rb +25 -0
- data/lib/ollama_agent/self_improvement/ruby_mastery_context.rb +66 -0
- data/lib/ollama_agent/self_improvement.rb +5 -0
- data/lib/ollama_agent/session/session.rb +8 -0
- data/lib/ollama_agent/session/store.rb +68 -0
- data/lib/ollama_agent/streaming/console_streamer.rb +29 -0
- data/lib/ollama_agent/streaming/hooks.rb +39 -0
- data/lib/ollama_agent/tool_arguments.rb +13 -1
- data/lib/ollama_agent/tool_content_parser.rb +1 -1
- data/lib/ollama_agent/tool_runtime/executor.rb +34 -0
- data/lib/ollama_agent/tool_runtime/json_extractor.rb +62 -0
- data/lib/ollama_agent/tool_runtime/loop.rb +72 -0
- data/lib/ollama_agent/tool_runtime/memory.rb +32 -0
- data/lib/ollama_agent/tool_runtime/ollama_json_planner.rb +98 -0
- data/lib/ollama_agent/tool_runtime/plan_extractor.rb +12 -0
- data/lib/ollama_agent/tool_runtime/registry.rb +60 -0
- data/lib/ollama_agent/tool_runtime/tool.rb +24 -0
- data/lib/ollama_agent/tool_runtime.rb +24 -0
- data/lib/ollama_agent/tools/registry.rb +55 -0
- data/lib/ollama_agent/tools_schema.rb +74 -1
- data/lib/ollama_agent/user_prompt.rb +35 -0
- data/lib/ollama_agent/version.rb +1 -1
- data/lib/ollama_agent.rb +25 -0
- data/reproduce_429.rb +40 -0
- data/sig/ollama_agent.rbs +111 -1
- metadata +78 -2
|
@@ -0,0 +1,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
|
-
|
|
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
|
data/lib/ollama_agent/version.rb
CHANGED
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
|