ollama_agent 0.3.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +14 -3
- data/lib/ollama_agent/agent/agent_config.rb +19 -2
- data/lib/ollama_agent/agent/client_wiring.rb +3 -8
- data/lib/ollama_agent/agent/session_wiring.rb +37 -3
- data/lib/ollama_agent/agent.rb +82 -6
- data/lib/ollama_agent/cli/repl.rb +159 -0
- data/lib/ollama_agent/cli/repl_shared.rb +229 -0
- data/lib/ollama_agent/cli/tui_repl.rb +149 -0
- data/lib/ollama_agent/cli.rb +129 -49
- data/lib/ollama_agent/core/action_envelope.rb +82 -0
- data/lib/ollama_agent/core/budget.rb +90 -0
- data/lib/ollama_agent/core/loop_detector.rb +67 -0
- data/lib/ollama_agent/core/schema_validator.rb +136 -0
- data/lib/ollama_agent/core/trace_logger.rb +138 -0
- data/lib/ollama_agent/external_agents/probe.rb +23 -3
- data/lib/ollama_agent/indexing/context_packer.rb +140 -0
- data/lib/ollama_agent/indexing/diff_summarizer.rb +125 -0
- data/lib/ollama_agent/indexing/file_indexer.rb +129 -0
- data/lib/ollama_agent/indexing/repo_scanner.rb +158 -0
- data/lib/ollama_agent/memory/long_term.rb +109 -0
- data/lib/ollama_agent/memory/manager.rb +121 -0
- data/lib/ollama_agent/memory/session_memory.rb +93 -0
- data/lib/ollama_agent/memory/short_term.rb +66 -0
- data/lib/ollama_agent/ollama_cloud_catalog.rb +66 -0
- data/lib/ollama_agent/ollama_connection.rb +30 -0
- data/lib/ollama_agent/plugins/loader.rb +95 -0
- data/lib/ollama_agent/plugins/registry.rb +103 -0
- data/lib/ollama_agent/providers/anthropic.rb +245 -0
- data/lib/ollama_agent/providers/base.rb +79 -0
- data/lib/ollama_agent/providers/ollama.rb +118 -0
- data/lib/ollama_agent/providers/openai.rb +215 -0
- data/lib/ollama_agent/providers/registry.rb +76 -0
- data/lib/ollama_agent/providers/router.rb +93 -0
- data/lib/ollama_agent/resilience/retry_middleware.rb +5 -0
- data/lib/ollama_agent/runner.rb +25 -4
- data/lib/ollama_agent/runtime/approval_gate.rb +74 -0
- data/lib/ollama_agent/runtime/permissions.rb +103 -0
- data/lib/ollama_agent/runtime/policies.rb +100 -0
- data/lib/ollama_agent/runtime/sandbox.rb +130 -0
- data/lib/ollama_agent/streaming/hooks.rb +3 -1
- data/lib/ollama_agent/tools/base.rb +108 -0
- data/lib/ollama_agent/tools/git_tools.rb +176 -0
- data/lib/ollama_agent/tools/http_tools.rb +202 -0
- data/lib/ollama_agent/tools/memory_tools.rb +116 -0
- data/lib/ollama_agent/tools/shell_tools.rb +208 -0
- data/lib/ollama_agent/tui.rb +183 -0
- data/lib/ollama_agent/tui_slash_reader.rb +147 -0
- data/lib/ollama_agent/tui_user_prompt.rb +45 -0
- data/lib/ollama_agent/version.rb +1 -1
- data/lib/ollama_agent.rb +46 -1
- metadata +142 -5
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OllamaAgent
|
|
4
|
+
module Core
|
|
5
|
+
# Wraps every structured response from the planner.
|
|
6
|
+
# Enforces a fixed contract so the runner stays deterministic.
|
|
7
|
+
#
|
|
8
|
+
# Types:
|
|
9
|
+
# :tool_call — execute a tool with args
|
|
10
|
+
# :final — model is done; surface content to the user
|
|
11
|
+
# :ask_clarification— model needs more info before proceeding
|
|
12
|
+
# :error — unrecoverable error in the action
|
|
13
|
+
# :handoff — delegate to another agent
|
|
14
|
+
class ActionEnvelope
|
|
15
|
+
VALID_TYPES = %i[tool_call final ask_clarification error handoff].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :type, :payload, :confidence, :envelope_id
|
|
18
|
+
|
|
19
|
+
def initialize(type:, payload:, confidence: nil, envelope_id: nil)
|
|
20
|
+
raise ArgumentError, "Unknown action type: #{type}" unless VALID_TYPES.include?(type.to_sym)
|
|
21
|
+
|
|
22
|
+
@type = type.to_sym
|
|
23
|
+
@payload = payload
|
|
24
|
+
@confidence = confidence
|
|
25
|
+
@envelope_id = envelope_id || generate_id
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# --- Factory constructors ---
|
|
29
|
+
|
|
30
|
+
def self.tool_call(tool:, args:, confidence: nil)
|
|
31
|
+
new(type: :tool_call, payload: { tool: tool.to_s, args: args || {} }, confidence: confidence)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.final(content:)
|
|
35
|
+
new(type: :final, payload: { content: content.to_s })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.ask_clarification(question:)
|
|
39
|
+
new(type: :ask_clarification, payload: { question: question.to_s })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.error(message:, cause: nil)
|
|
43
|
+
new(type: :error, payload: { message: message.to_s, cause: cause })
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.handoff(agent:, query:)
|
|
47
|
+
new(type: :handoff, payload: { agent: agent.to_s, query: query.to_s })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# --- Predicate helpers ---
|
|
51
|
+
|
|
52
|
+
def tool_call? = @type == :tool_call
|
|
53
|
+
def final? = @type == :final
|
|
54
|
+
def ask_clarification? = @type == :ask_clarification
|
|
55
|
+
def error? = @type == :error
|
|
56
|
+
def handoff? = @type == :handoff
|
|
57
|
+
|
|
58
|
+
# --- Accessors for common payload fields ---
|
|
59
|
+
|
|
60
|
+
def tool = @payload[:tool]
|
|
61
|
+
def args = @payload[:args] || {}
|
|
62
|
+
def content = @payload[:content]
|
|
63
|
+
def question = @payload[:question]
|
|
64
|
+
def message = @payload[:message]
|
|
65
|
+
|
|
66
|
+
def to_h
|
|
67
|
+
{ type: @type, payload: @payload, confidence: @confidence, envelope_id: @envelope_id }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_s
|
|
71
|
+
"#<ActionEnvelope type=#{@type} id=#{@envelope_id}>"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def generate_id
|
|
77
|
+
require "securerandom"
|
|
78
|
+
"env_#{SecureRandom.hex(6)}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OllamaAgent
|
|
4
|
+
module Core
|
|
5
|
+
# Tracks and enforces token, step, and cost budgets for an agent run.
|
|
6
|
+
# Instantiate once per run; call #record_step! after each model round-trip.
|
|
7
|
+
class Budget
|
|
8
|
+
DEFAULT_MAX_STEPS = 64
|
|
9
|
+
DEFAULT_MAX_TOKENS = 32_768
|
|
10
|
+
DEFAULT_MAX_COST_USD = nil
|
|
11
|
+
|
|
12
|
+
attr_reader :steps, :tokens_used, :cost_usd,
|
|
13
|
+
:max_steps, :max_tokens, :max_cost_usd
|
|
14
|
+
|
|
15
|
+
def initialize(max_steps: nil, max_tokens: nil, max_cost_usd: nil)
|
|
16
|
+
@max_steps = max_steps || env_int("OLLAMA_AGENT_MAX_TURNS", DEFAULT_MAX_STEPS)
|
|
17
|
+
@max_tokens = max_tokens || env_int("OLLAMA_AGENT_MAX_TOKENS", DEFAULT_MAX_TOKENS)
|
|
18
|
+
@max_cost_usd = max_cost_usd || env_float("OLLAMA_AGENT_MAX_COST_USD", DEFAULT_MAX_COST_USD)
|
|
19
|
+
|
|
20
|
+
reset!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Record one agent step. Call after each model response.
|
|
24
|
+
# @param tokens [Integer] tokens consumed in this step (prompt + completion)
|
|
25
|
+
# @param cost_usd [Float] estimated cost in USD (0.0 for local models)
|
|
26
|
+
def record_step!(tokens: 0, cost_usd: 0.0)
|
|
27
|
+
@steps += 1
|
|
28
|
+
@tokens_used += tokens.to_i
|
|
29
|
+
@cost_usd += cost_usd.to_f
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def steps_exceeded? = @steps >= @max_steps
|
|
34
|
+
def tokens_exceeded? = @tokens_used >= @max_tokens
|
|
35
|
+
def cost_exceeded? = !@max_cost_usd.nil? && @cost_usd >= @max_cost_usd
|
|
36
|
+
|
|
37
|
+
# True when any limit has been hit.
|
|
38
|
+
def exceeded?
|
|
39
|
+
steps_exceeded? || tokens_exceeded? || cost_exceeded?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Human-readable reason for the first exceeded limit, or nil if none.
|
|
43
|
+
def exceeded_reason
|
|
44
|
+
return "step limit (#{@max_steps})" if steps_exceeded?
|
|
45
|
+
return "token limit (#{@max_tokens})" if tokens_exceeded?
|
|
46
|
+
return "cost limit ($#{@max_cost_usd})" if cost_exceeded?
|
|
47
|
+
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def reset!
|
|
52
|
+
@steps = 0
|
|
53
|
+
@tokens_used = 0
|
|
54
|
+
@cost_usd = 0.0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_h
|
|
58
|
+
{
|
|
59
|
+
steps: @steps, max_steps: @max_steps,
|
|
60
|
+
tokens_used: @tokens_used, max_tokens: @max_tokens,
|
|
61
|
+
cost_usd: @cost_usd, max_cost_usd: @max_cost_usd
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def remaining_steps
|
|
66
|
+
[@max_steps - @steps, 0].max
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def env_int(key, default)
|
|
72
|
+
v = ENV.fetch(key, nil)
|
|
73
|
+
return default if v.nil? || v.strip.empty?
|
|
74
|
+
|
|
75
|
+
Integer(v)
|
|
76
|
+
rescue ArgumentError, TypeError
|
|
77
|
+
default
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def env_float(key, default)
|
|
81
|
+
v = ENV.fetch(key, nil)
|
|
82
|
+
return default if v.nil? || v.strip.empty?
|
|
83
|
+
|
|
84
|
+
Float(v)
|
|
85
|
+
rescue ArgumentError, TypeError
|
|
86
|
+
default
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OllamaAgent
|
|
4
|
+
module Core
|
|
5
|
+
# Detects when the agent is stuck in a repeating tool-call loop.
|
|
6
|
+
#
|
|
7
|
+
# Strategy: keep a sliding window of (tool_name + args_fingerprint) tokens.
|
|
8
|
+
# If the most-recent window pattern appears THRESHOLD or more times in the
|
|
9
|
+
# accumulated history, we declare a loop.
|
|
10
|
+
class LoopDetector
|
|
11
|
+
DEFAULT_WINDOW = 4 # number of consecutive calls to treat as one pattern
|
|
12
|
+
DEFAULT_THRESHOLD = 2 # how many times the pattern must repeat
|
|
13
|
+
|
|
14
|
+
attr_reader :history
|
|
15
|
+
|
|
16
|
+
def initialize(window: DEFAULT_WINDOW, threshold: DEFAULT_THRESHOLD)
|
|
17
|
+
@window = window.to_i.clamp(1, 32)
|
|
18
|
+
@threshold = threshold.to_i.clamp(2, 16)
|
|
19
|
+
@history = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Record a tool call. Should be called before executing each tool.
|
|
23
|
+
# @param tool_name [String]
|
|
24
|
+
# @param args [Hash, String]
|
|
25
|
+
def record!(tool_name, args = {})
|
|
26
|
+
@history << fingerprint(tool_name, args)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns true when the recent pattern has repeated enough times.
|
|
30
|
+
def loop_detected?
|
|
31
|
+
return false if @history.size < @window * @threshold
|
|
32
|
+
|
|
33
|
+
pattern = @history.last(@window)
|
|
34
|
+
matches = 0
|
|
35
|
+
|
|
36
|
+
(@history.size - @window + 1).times do |i|
|
|
37
|
+
matches += 1 if @history[i, @window] == pattern
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
matches >= @threshold
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Human-readable description of the detected loop.
|
|
44
|
+
def loop_summary
|
|
45
|
+
return nil unless loop_detected?
|
|
46
|
+
|
|
47
|
+
pattern = @history.last(@window)
|
|
48
|
+
"Loop detected: pattern [#{pattern.join(" → ")}] repeated #{@threshold}+ times"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def reset!
|
|
52
|
+
@history.clear
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def fingerprint(tool_name, args)
|
|
58
|
+
stable = case args
|
|
59
|
+
when Hash then args.sort.map { |k, v| "#{k}=#{v}" }.join(",")
|
|
60
|
+
when Array then args.map(&:to_s).join(",")
|
|
61
|
+
else args.to_s
|
|
62
|
+
end
|
|
63
|
+
"#{tool_name}(#{stable[0, 80]})"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OllamaAgent
|
|
4
|
+
module Core
|
|
5
|
+
# Lightweight JSON-schema validator for tool arguments.
|
|
6
|
+
# Supports: type, required, properties, enum, minimum, maximum, minLength, maxLength.
|
|
7
|
+
# Does NOT require the json-schema gem — all validation is hand-rolled for zero dependencies.
|
|
8
|
+
class SchemaValidator
|
|
9
|
+
ValidationError = Class.new(StandardError)
|
|
10
|
+
|
|
11
|
+
# Validate +data+ against +schema+.
|
|
12
|
+
# @param schema [Hash] JSON schema (symbol or string keys)
|
|
13
|
+
# @param data [Hash] data to validate
|
|
14
|
+
# @return [Array<String>] list of error messages (empty = valid)
|
|
15
|
+
def validate(schema, data)
|
|
16
|
+
@errors = []
|
|
17
|
+
schema = stringify_keys(schema)
|
|
18
|
+
data = stringify_keys(data || {})
|
|
19
|
+
|
|
20
|
+
check_type(schema, data)
|
|
21
|
+
check_required(schema, data)
|
|
22
|
+
check_properties(schema, data)
|
|
23
|
+
|
|
24
|
+
@errors.dup
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Raises ValidationError if any errors found.
|
|
28
|
+
def validate!(schema, data)
|
|
29
|
+
errors = validate(schema, data)
|
|
30
|
+
raise ValidationError, errors.join("; ") if errors.any?
|
|
31
|
+
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def check_type(schema, data)
|
|
38
|
+
expected = schema["type"]
|
|
39
|
+
return if expected.nil?
|
|
40
|
+
|
|
41
|
+
actual = ruby_type(data)
|
|
42
|
+
unless type_match?(expected, actual, data)
|
|
43
|
+
@errors << "expected type #{expected}, got #{actual}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def check_required(schema, data)
|
|
48
|
+
required = schema["required"]
|
|
49
|
+
return unless required.is_a?(Array)
|
|
50
|
+
|
|
51
|
+
required.each do |field|
|
|
52
|
+
@errors << "missing required field: #{field}" unless data.key?(field.to_s)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def check_properties(schema, data)
|
|
57
|
+
props = schema["properties"]
|
|
58
|
+
return unless props.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
props.each do |prop_name, prop_schema|
|
|
61
|
+
next unless data.key?(prop_name.to_s)
|
|
62
|
+
|
|
63
|
+
value = data[prop_name.to_s]
|
|
64
|
+
prop_errors = self.class.new.validate(prop_schema, value)
|
|
65
|
+
prop_errors.each { |e| @errors << "#{prop_name}: #{e}" }
|
|
66
|
+
check_constraints(prop_name, prop_schema, value)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def check_constraints(name, schema, value)
|
|
71
|
+
check_enum(name, schema, value)
|
|
72
|
+
check_string_length(name, schema, value)
|
|
73
|
+
check_numeric_range(name, schema, value)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def check_enum(name, schema, value)
|
|
77
|
+
allowed = schema["enum"]
|
|
78
|
+
return unless allowed.is_a?(Array)
|
|
79
|
+
return if allowed.include?(value)
|
|
80
|
+
|
|
81
|
+
@errors << "#{name}: must be one of #{allowed.inspect}, got #{value.inspect}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def check_string_length(name, schema, value)
|
|
85
|
+
return unless value.is_a?(String)
|
|
86
|
+
|
|
87
|
+
min_len = schema["minLength"]
|
|
88
|
+
max_len = schema["maxLength"]
|
|
89
|
+
|
|
90
|
+
if min_len && value.length < min_len
|
|
91
|
+
@errors << "#{name}: length #{value.length} is less than minLength #{min_len}"
|
|
92
|
+
end
|
|
93
|
+
if max_len && value.length > max_len
|
|
94
|
+
@errors << "#{name}: length #{value.length} exceeds maxLength #{max_len}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def check_numeric_range(name, schema, value)
|
|
99
|
+
return unless value.is_a?(Numeric)
|
|
100
|
+
|
|
101
|
+
minimum = schema["minimum"]
|
|
102
|
+
maximum = schema["maximum"]
|
|
103
|
+
|
|
104
|
+
@errors << "#{name}: #{value} is less than minimum #{minimum}" if minimum && value < minimum
|
|
105
|
+
@errors << "#{name}: #{value} exceeds maximum #{maximum}" if maximum && value > maximum
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def ruby_type(value)
|
|
109
|
+
case value
|
|
110
|
+
when Hash then "object"
|
|
111
|
+
when Array then "array"
|
|
112
|
+
when String then "string"
|
|
113
|
+
when Integer then "integer"
|
|
114
|
+
when Float then "number"
|
|
115
|
+
when TrueClass, FalseClass then "boolean"
|
|
116
|
+
when NilClass then "null"
|
|
117
|
+
else "unknown"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def type_match?(expected, actual, data)
|
|
122
|
+
return actual == expected unless expected == "number"
|
|
123
|
+
|
|
124
|
+
data.is_a?(Numeric)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def stringify_keys(obj)
|
|
128
|
+
return obj unless obj.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
obj.transform_keys(&:to_s).transform_values do |v|
|
|
131
|
+
v.is_a?(Hash) ? stringify_keys(v) : v
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module OllamaAgent
|
|
8
|
+
module Core
|
|
9
|
+
# Structured observability logger for agent runs.
|
|
10
|
+
# Tracks: run_id, step_id, tool_call_id, latency, token usage,
|
|
11
|
+
# retry count, fallback usage, schema failures, user approvals.
|
|
12
|
+
#
|
|
13
|
+
# Supports three output modes (set via :format):
|
|
14
|
+
# :human — colored, readable (default)
|
|
15
|
+
# :json — NDJSON file under log_dir
|
|
16
|
+
# :debug — human + full payload dump
|
|
17
|
+
class TraceLogger
|
|
18
|
+
FORMATS = %i[human json debug].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :run_id
|
|
21
|
+
|
|
22
|
+
def initialize(log_dir: nil, format: :human, hooks: nil)
|
|
23
|
+
@log_dir = log_dir
|
|
24
|
+
@format = FORMATS.include?(format.to_sym) ? format.to_sym : :human
|
|
25
|
+
@hooks = hooks
|
|
26
|
+
@run_id = "run_#{SecureRandom.hex(8)}"
|
|
27
|
+
@step = 0
|
|
28
|
+
|
|
29
|
+
attach_to_hooks if @hooks
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Emit a structured trace event.
|
|
33
|
+
# @param event [Symbol] event name
|
|
34
|
+
# @param payload [Hash]
|
|
35
|
+
def trace(event, payload = {})
|
|
36
|
+
entry = build_entry(event, payload)
|
|
37
|
+
write_entry(entry)
|
|
38
|
+
entry
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def start_run(query: nil)
|
|
42
|
+
trace(:run_start, { query: query, run_id: @run_id })
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def end_run(turns:, budget: nil)
|
|
46
|
+
trace(:run_end, { turns: turns, budget: budget&.to_h, run_id: @run_id })
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def tool_call(name:, args:, turn:, call_id: nil)
|
|
50
|
+
@step += 1
|
|
51
|
+
trace(:tool_call, { name: name, args: args, turn: turn,
|
|
52
|
+
step: @step, call_id: call_id || step_id })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def tool_result(name:, result:, turn:, latency_ms: nil, call_id: nil)
|
|
56
|
+
trace(:tool_result, { name: name, bytes: result.to_s.bytesize,
|
|
57
|
+
turn: turn, latency_ms: latency_ms, call_id: call_id })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def schema_failure(tool:, errors:)
|
|
61
|
+
trace(:schema_failure, { tool: tool, errors: errors })
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def user_approval(tool:, approved:)
|
|
65
|
+
trace(:user_approval, { tool: tool, approved: approved })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def retry_attempt(attempt:, delay_ms:, error:)
|
|
69
|
+
trace(:http_retry, { attempt: attempt, delay_ms: delay_ms,
|
|
70
|
+
error: error.class.name, message: error.message })
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def fallback_used(from_provider:, to_provider:, reason:)
|
|
74
|
+
trace(:provider_fallback, { from: from_provider, to: to_provider, reason: reason })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def loop_detected(summary:)
|
|
78
|
+
trace(:loop_detected, { summary: summary })
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def budget_exceeded(reason:)
|
|
82
|
+
trace(:budget_exceeded, { reason: reason })
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def attach_to_hooks
|
|
88
|
+
@hooks.on(:on_tool_call) { |p| tool_call(name: p[:name], args: p[:args], turn: p[:turn]) }
|
|
89
|
+
@hooks.on(:on_tool_result) { |p| tool_result(name: p[:name], result: p[:result], turn: p[:turn]) }
|
|
90
|
+
@hooks.on(:on_retry) { |p| retry_attempt(attempt: p[:attempt], delay_ms: p[:delay_ms], error: p[:error]) }
|
|
91
|
+
@hooks.on(:on_complete) { |p| end_run(turns: p[:turns]) }
|
|
92
|
+
@hooks.on(:on_error) { |p| trace(:agent_error, { error: p[:error].class.name, message: p[:error].message }) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_entry(event, payload)
|
|
96
|
+
{ ts: timestamp, run_id: @run_id, event: event }.merge(payload)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def write_entry(entry)
|
|
100
|
+
case @format
|
|
101
|
+
when :json then write_json(entry)
|
|
102
|
+
when :debug then write_debug(entry)
|
|
103
|
+
else write_human(entry)
|
|
104
|
+
end
|
|
105
|
+
rescue StandardError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def write_json(entry)
|
|
110
|
+
return unless @log_dir
|
|
111
|
+
|
|
112
|
+
FileUtils.mkdir_p(@log_dir)
|
|
113
|
+
path = File.join(@log_dir, "#{Date.today}.trace.ndjson")
|
|
114
|
+
File.open(path, "a", encoding: Encoding::UTF_8) { |f| f.puts(JSON.generate(entry)) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def write_human(entry)
|
|
118
|
+
event = entry[:event].to_s.ljust(20)
|
|
119
|
+
detail = entry.reject { |k, _| %i[ts run_id event].include?(k) }
|
|
120
|
+
.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
|
|
121
|
+
warn "[#{entry[:ts]}] #{event} #{detail}" if ENV["OLLAMA_AGENT_TRACE"] == "1"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def write_debug(entry)
|
|
125
|
+
warn "[TRACE] #{JSON.generate(entry)}" if ENV["OLLAMA_AGENT_DEBUG"] == "1"
|
|
126
|
+
write_json(entry) if @log_dir
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def timestamp
|
|
130
|
+
Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def step_id
|
|
134
|
+
"step_#{@run_id}_#{@step}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -21,10 +21,10 @@ module OllamaAgent
|
|
|
21
21
|
name = agent["binary"].to_s
|
|
22
22
|
return nil if name.empty?
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
return
|
|
24
|
+
resolved = resolve_via_command_v(name)
|
|
25
|
+
return resolved if resolved
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
resolve_via_path_walk(name)
|
|
28
28
|
end
|
|
29
29
|
# rubocop:enable Metrics/AbcSize
|
|
30
30
|
|
|
@@ -113,6 +113,26 @@ module OllamaAgent
|
|
|
113
113
|
|
|
114
114
|
private
|
|
115
115
|
|
|
116
|
+
# POSIX `command -v` when /usr/bin/command exists; rescues ENOENT when `command` is shell-only.
|
|
117
|
+
def resolve_via_command_v(name)
|
|
118
|
+
out, status = Open3.capture2("command", "-v", name)
|
|
119
|
+
return out.strip if status.success? && !out.to_s.strip.empty?
|
|
120
|
+
|
|
121
|
+
nil
|
|
122
|
+
rescue Errno::ENOENT
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def resolve_via_path_walk(name)
|
|
127
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
|
|
128
|
+
next if dir.empty?
|
|
129
|
+
|
|
130
|
+
abs = File.join(dir, name)
|
|
131
|
+
return abs if File.file?(abs) && File.executable?(abs)
|
|
132
|
+
end
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
116
136
|
def status_cache
|
|
117
137
|
@status_cache ||= {}
|
|
118
138
|
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "repo_scanner"
|
|
4
|
+
require_relative "file_indexer"
|
|
5
|
+
|
|
6
|
+
module OllamaAgent
|
|
7
|
+
module Indexing
|
|
8
|
+
# Builds a compact, query-relevant context block from the repository.
|
|
9
|
+
# Used to inject surgical context into the system prompt instead of
|
|
10
|
+
# reading entire files blindly.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# packer = OllamaAgent::Indexing::ContextPacker.new(root: Dir.pwd)
|
|
14
|
+
# context = packer.pack(query: "fix the authentication module")
|
|
15
|
+
# # => "# lib/auth/session.rb\n```ruby\n...\n```\n\n# lib/auth/token.rb\n..."
|
|
16
|
+
class ContextPacker
|
|
17
|
+
DEFAULT_MAX_FILES = 15
|
|
18
|
+
DEFAULT_MAX_FILE_BYTES = 8_192 # 8 KB per file in context
|
|
19
|
+
DEFAULT_MAX_TOTAL_BYTES = 65_536 # 64 KB total context
|
|
20
|
+
|
|
21
|
+
def initialize(root:, max_files: DEFAULT_MAX_FILES,
|
|
22
|
+
max_file_bytes: DEFAULT_MAX_FILE_BYTES,
|
|
23
|
+
max_total_bytes: DEFAULT_MAX_TOTAL_BYTES,
|
|
24
|
+
indexer: nil)
|
|
25
|
+
@root = File.expand_path(root)
|
|
26
|
+
@max_files = max_files
|
|
27
|
+
@max_file_bytes = max_file_bytes
|
|
28
|
+
@max_total_bytes = max_total_bytes
|
|
29
|
+
@indexer = indexer || FileIndexer.new(root: @root)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Pack the most relevant files for a query.
|
|
33
|
+
# @param query [String] natural-language query for scoring
|
|
34
|
+
# @param files [Array, nil] explicit relative paths (bypasses scoring)
|
|
35
|
+
# @param languages [Array, nil] filter to specific languages
|
|
36
|
+
# @return [String] formatted context block (Markdown fenced code)
|
|
37
|
+
def pack(query: nil, files: nil, languages: nil)
|
|
38
|
+
candidates = if files
|
|
39
|
+
files.map { |f| File.expand_path(f, @root) }
|
|
40
|
+
elsif query
|
|
41
|
+
relevant_paths(query, languages: languages)
|
|
42
|
+
else
|
|
43
|
+
recently_modified_paths
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
build_context(candidates)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Return a repo-summary block (structure, file counts, recent changes).
|
|
50
|
+
def repo_summary
|
|
51
|
+
scanner = RepoScanner.new(root: @root)
|
|
52
|
+
stats = scanner.stats
|
|
53
|
+
|
|
54
|
+
lines = ["## Repository Summary", "Root: #{@root}", ""]
|
|
55
|
+
lines << "### File counts by language"
|
|
56
|
+
stats[:languages]
|
|
57
|
+
.sort_by { |_, v| -v[:files] }
|
|
58
|
+
.first(10)
|
|
59
|
+
.each { |lang, info| lines << "- #{lang}: #{info[:files]} files (#{human_bytes(info[:bytes])})" }
|
|
60
|
+
|
|
61
|
+
lines << ""
|
|
62
|
+
lines << "### Recently modified (top 10)"
|
|
63
|
+
scanner.recently_modified(n: 10).each do |f|
|
|
64
|
+
lines << "- #{f.relative_path} (#{human_bytes(f.size)})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
lines.join("\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def relevant_paths(query, languages: nil)
|
|
73
|
+
entries = @indexer.search(query, top_n: @max_files, languages: languages)
|
|
74
|
+
entries.map { |e| File.join(@root, e.relative_path) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def recently_modified_paths
|
|
78
|
+
scanner = RepoScanner.new(root: @root)
|
|
79
|
+
scanner.recently_modified(n: @max_files).map(&:path)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_context(paths)
|
|
83
|
+
parts = []
|
|
84
|
+
total_bytes = 0
|
|
85
|
+
|
|
86
|
+
paths.each do |abs_path|
|
|
87
|
+
break if total_bytes >= @max_total_bytes
|
|
88
|
+
break if parts.size >= @max_files
|
|
89
|
+
|
|
90
|
+
next unless File.file?(abs_path)
|
|
91
|
+
|
|
92
|
+
rel = abs_path.sub("#{@root}/", "")
|
|
93
|
+
content = read_truncated(abs_path)
|
|
94
|
+
lang = detect_fence_lang(abs_path)
|
|
95
|
+
|
|
96
|
+
block = "### #{rel}\n```#{lang}\n#{content}\n```"
|
|
97
|
+
block_bytes = block.bytesize
|
|
98
|
+
|
|
99
|
+
break if total_bytes + block_bytes > @max_total_bytes
|
|
100
|
+
|
|
101
|
+
parts << block
|
|
102
|
+
total_bytes += block_bytes
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
parts.empty? ? "" : parts.join("\n\n")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def read_truncated(path)
|
|
109
|
+
raw = File.read(path, encoding: "utf-8", invalid: :replace)
|
|
110
|
+
if raw.bytesize > @max_file_bytes
|
|
111
|
+
raw.byteslice(0, @max_file_bytes) + "\n# ... [truncated — #{human_bytes(raw.bytesize)} total]"
|
|
112
|
+
else
|
|
113
|
+
raw
|
|
114
|
+
end
|
|
115
|
+
rescue StandardError
|
|
116
|
+
"[unreadable]"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def detect_fence_lang(path)
|
|
120
|
+
ext_map = {
|
|
121
|
+
".rb" => "ruby", ".js" => "javascript", ".ts" => "typescript",
|
|
122
|
+
".py" => "python", ".go" => "go", ".rs" => "rust", ".java" => "java",
|
|
123
|
+
".sh" => "bash", ".yml" => "yaml", ".yaml" => "yaml",
|
|
124
|
+
".json" => "json", ".md" => "markdown", ".html" => "html",
|
|
125
|
+
".css" => "css", ".sql" => "sql", ".ex" => "elixir", ".exs" => "elixir"
|
|
126
|
+
}
|
|
127
|
+
ext_map[File.extname(path).downcase] || ""
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def human_bytes(n)
|
|
131
|
+
return "#{n} B" if n < 1024
|
|
132
|
+
|
|
133
|
+
n /= 1024.0
|
|
134
|
+
return "#{n.round(1)} KB" if n < 1024
|
|
135
|
+
|
|
136
|
+
"#{(n / 1024.0).round(1)} MB"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|