ollama-client 0.2.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 +7 -0
- data/CHANGELOG.md +12 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +36 -0
- data/LICENSE.txt +21 -0
- data/PRODUCTION_FIXES.md +172 -0
- data/README.md +690 -0
- data/Rakefile +12 -0
- data/TESTING.md +286 -0
- data/examples/advanced_complex_schemas.rb +363 -0
- data/examples/advanced_edge_cases.rb +241 -0
- data/examples/advanced_error_handling.rb +200 -0
- data/examples/advanced_multi_step_agent.rb +258 -0
- data/examples/advanced_performance_testing.rb +186 -0
- data/examples/complete_workflow.rb +235 -0
- data/examples/dhanhq_agent.rb +752 -0
- data/examples/dhanhq_tools.rb +563 -0
- data/examples/structured_outputs_chat.rb +72 -0
- data/examples/tool_calling_pattern.rb +266 -0
- data/exe/ollama-client +4 -0
- data/lib/ollama/agent/executor.rb +157 -0
- data/lib/ollama/agent/messages.rb +31 -0
- data/lib/ollama/agent/planner.rb +47 -0
- data/lib/ollama/client.rb +775 -0
- data/lib/ollama/config.rb +29 -0
- data/lib/ollama/errors.rb +54 -0
- data/lib/ollama/schema_validator.rb +79 -0
- data/lib/ollama/schemas/base.json +5 -0
- data/lib/ollama/streaming_observer.rb +22 -0
- data/lib/ollama/version.rb +5 -0
- data/lib/ollama_client.rb +46 -0
- data/sig/ollama/client.rbs +6 -0
- metadata +108 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Advanced Example: Edge Cases and Boundary Testing
|
|
5
|
+
# Demonstrates: Empty responses, malformed schemas, extreme values, special characters
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
require_relative "../lib/ollama_client"
|
|
9
|
+
|
|
10
|
+
class EdgeCaseTester
|
|
11
|
+
def initialize(client:)
|
|
12
|
+
@client = client
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def test_empty_prompt
|
|
16
|
+
puts "Test 1: Empty prompt"
|
|
17
|
+
schema = {
|
|
18
|
+
"type" => "object",
|
|
19
|
+
"properties" => {
|
|
20
|
+
"response" => { "type" => "string" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
result = @client.generate(prompt: "", schema: schema)
|
|
26
|
+
puts " ✅ Handled empty prompt: #{result.inspect[0..100]}"
|
|
27
|
+
rescue Ollama::Error => e
|
|
28
|
+
puts " ❌ Error: #{e.class} - #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
puts
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_very_long_prompt
|
|
34
|
+
puts "Test 2: Very long prompt (10KB+)"
|
|
35
|
+
long_prompt = "Repeat this sentence. " * 500
|
|
36
|
+
schema = {
|
|
37
|
+
"type" => "object",
|
|
38
|
+
"properties" => {
|
|
39
|
+
"summary" => { "type" => "string" }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
@client.generate(prompt: long_prompt, schema: schema)
|
|
45
|
+
puts " ✅ Handled long prompt (#{long_prompt.length} chars)"
|
|
46
|
+
rescue Ollama::Error => e
|
|
47
|
+
puts " ❌ Error: #{e.class} - #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
puts
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_special_characters
|
|
53
|
+
puts "Test 3: Special characters in prompt"
|
|
54
|
+
special_prompt = "Analyze: !@#$%^&*()_+-=[]{}|;':\",./<>?`~"
|
|
55
|
+
schema = {
|
|
56
|
+
"type" => "object",
|
|
57
|
+
"properties" => {
|
|
58
|
+
"analysis" => { "type" => "string" }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
@client.generate(prompt: special_prompt, schema: schema)
|
|
64
|
+
puts " ✅ Handled special characters"
|
|
65
|
+
rescue Ollama::Error => e
|
|
66
|
+
puts " ❌ Error: #{e.class} - #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
puts
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def test_unicode_characters
|
|
72
|
+
puts "Test 4: Unicode characters"
|
|
73
|
+
unicode_prompt = "Analyze: 你好世界 🌍 🚀 émojis and spéciál chäracters"
|
|
74
|
+
schema = {
|
|
75
|
+
"type" => "object",
|
|
76
|
+
"properties" => {
|
|
77
|
+
"analysis" => { "type" => "string" }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
@client.generate(prompt: unicode_prompt, schema: schema)
|
|
83
|
+
puts " ✅ Handled unicode characters"
|
|
84
|
+
rescue Ollama::Error => e
|
|
85
|
+
puts " ❌ Error: #{e.class} - #{e.message}"
|
|
86
|
+
end
|
|
87
|
+
puts
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_minimal_schema
|
|
91
|
+
puts "Test 5: Minimal schema (no required fields)"
|
|
92
|
+
schema = {
|
|
93
|
+
"type" => "object",
|
|
94
|
+
"additionalProperties" => true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
begin
|
|
98
|
+
result = @client.generate(
|
|
99
|
+
prompt: "Return any JSON object",
|
|
100
|
+
schema: schema
|
|
101
|
+
)
|
|
102
|
+
puts " ✅ Handled minimal schema: #{result.keys.join(', ')}"
|
|
103
|
+
rescue Ollama::Error => e
|
|
104
|
+
puts " ❌ Error: #{e.class} - #{e.message}"
|
|
105
|
+
end
|
|
106
|
+
puts
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def test_strict_schema
|
|
110
|
+
puts "Test 6: Strict schema with many constraints"
|
|
111
|
+
strict_schema = {
|
|
112
|
+
"type" => "object",
|
|
113
|
+
"required" => ["id", "name", "values"],
|
|
114
|
+
"properties" => {
|
|
115
|
+
"id" => {
|
|
116
|
+
"type" => "integer",
|
|
117
|
+
"minimum" => 1,
|
|
118
|
+
"maximum" => 1000
|
|
119
|
+
},
|
|
120
|
+
"name" => {
|
|
121
|
+
"type" => "string",
|
|
122
|
+
"minLength" => 3,
|
|
123
|
+
"maxLength" => 20,
|
|
124
|
+
"pattern" => "^[A-Za-z0-9_]+$"
|
|
125
|
+
},
|
|
126
|
+
"values" => {
|
|
127
|
+
"type" => "array",
|
|
128
|
+
"minItems" => 2,
|
|
129
|
+
"maxItems" => 5,
|
|
130
|
+
"items" => {
|
|
131
|
+
"type" => "number",
|
|
132
|
+
"minimum" => 0,
|
|
133
|
+
"maximum" => 100
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
begin
|
|
140
|
+
prompt_text = "Generate a valid object with id (1-1000), " \
|
|
141
|
+
"name (3-20 alphanumeric chars), " \
|
|
142
|
+
"and values (2-5 numbers 0-100)"
|
|
143
|
+
result = @client.generate(
|
|
144
|
+
prompt: prompt_text,
|
|
145
|
+
schema: strict_schema
|
|
146
|
+
)
|
|
147
|
+
puts " ✅ Handled strict schema"
|
|
148
|
+
puts " ID: #{result['id']}, Name: #{result['name']}, Values: #{result['values']}"
|
|
149
|
+
rescue Ollama::Error => e
|
|
150
|
+
puts " ❌ Error: #{e.class} - #{e.message}"
|
|
151
|
+
end
|
|
152
|
+
puts
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def test_nested_arrays
|
|
156
|
+
puts "Test 7: Deeply nested arrays"
|
|
157
|
+
nested_schema = {
|
|
158
|
+
"type" => "object",
|
|
159
|
+
"properties" => {
|
|
160
|
+
"matrix" => {
|
|
161
|
+
"type" => "array",
|
|
162
|
+
"items" => {
|
|
163
|
+
"type" => "array",
|
|
164
|
+
"items" => {
|
|
165
|
+
"type" => "array",
|
|
166
|
+
"items" => { "type" => "integer" }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
begin
|
|
174
|
+
@client.generate(
|
|
175
|
+
prompt: "Generate a 2x2x2 matrix of integers",
|
|
176
|
+
schema: nested_schema
|
|
177
|
+
)
|
|
178
|
+
puts " ✅ Handled nested arrays"
|
|
179
|
+
rescue Ollama::Error => e
|
|
180
|
+
puts " ❌ Error: #{e.class} - #{e.message}"
|
|
181
|
+
end
|
|
182
|
+
puts
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def test_enum_constraints
|
|
186
|
+
puts "Test 8: Strict enum constraints"
|
|
187
|
+
enum_schema = {
|
|
188
|
+
"type" => "object",
|
|
189
|
+
"required" => ["status", "priority"],
|
|
190
|
+
"properties" => {
|
|
191
|
+
"status" => {
|
|
192
|
+
"type" => "string",
|
|
193
|
+
"enum" => ["pending", "in_progress", "completed", "failed"]
|
|
194
|
+
},
|
|
195
|
+
"priority" => {
|
|
196
|
+
"type" => "string",
|
|
197
|
+
"enum" => ["low", "medium", "high", "urgent"]
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
begin
|
|
203
|
+
result = @client.generate(
|
|
204
|
+
prompt: "Choose a status and priority from the allowed values",
|
|
205
|
+
schema: enum_schema
|
|
206
|
+
)
|
|
207
|
+
puts " ✅ Handled enum constraints: status=#{result['status']}, priority=#{result['priority']}"
|
|
208
|
+
rescue Ollama::Error => e
|
|
209
|
+
puts " ❌ Error: #{e.class} - #{e.message}"
|
|
210
|
+
end
|
|
211
|
+
puts
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def run_all_tests
|
|
215
|
+
puts "=" * 60
|
|
216
|
+
puts "Edge Case Testing Suite"
|
|
217
|
+
puts "=" * 60
|
|
218
|
+
puts
|
|
219
|
+
|
|
220
|
+
test_empty_prompt
|
|
221
|
+
test_very_long_prompt
|
|
222
|
+
test_special_characters
|
|
223
|
+
test_unicode_characters
|
|
224
|
+
test_minimal_schema
|
|
225
|
+
test_strict_schema
|
|
226
|
+
test_nested_arrays
|
|
227
|
+
test_enum_constraints
|
|
228
|
+
|
|
229
|
+
puts "=" * 60
|
|
230
|
+
puts "Edge case testing complete!"
|
|
231
|
+
puts "=" * 60
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Run tests
|
|
236
|
+
if __FILE__ == $PROGRAM_NAME
|
|
237
|
+
client = Ollama::Client.new
|
|
238
|
+
tester = EdgeCaseTester.new(client: client)
|
|
239
|
+
tester.run_all_tests
|
|
240
|
+
end
|
|
241
|
+
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Advanced Example: Comprehensive Error Handling and Recovery Patterns
|
|
5
|
+
# Demonstrates: All error types, retry strategies, fallback mechanisms, observability
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
require_relative "../lib/ollama_client"
|
|
9
|
+
|
|
10
|
+
class ResilientAgent
|
|
11
|
+
def initialize(client:)
|
|
12
|
+
@client = client
|
|
13
|
+
@stats = {
|
|
14
|
+
total_calls: 0,
|
|
15
|
+
successes: 0,
|
|
16
|
+
retries: 0,
|
|
17
|
+
failures: 0,
|
|
18
|
+
errors_by_type: {}
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
23
|
+
def execute_with_resilience(prompt:, schema:, max_attempts: 3)
|
|
24
|
+
@stats[:total_calls] += 1
|
|
25
|
+
attempt = 0
|
|
26
|
+
|
|
27
|
+
loop do
|
|
28
|
+
attempt += 1
|
|
29
|
+
puts "\n📞 Attempt #{attempt}/#{max_attempts}"
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
result = @client.generate(prompt: prompt, schema: schema)
|
|
33
|
+
@stats[:successes] += 1
|
|
34
|
+
puts "✅ Success on attempt #{attempt}"
|
|
35
|
+
return { success: true, result: result, attempts: attempt }
|
|
36
|
+
rescue Ollama::NotFoundError => e
|
|
37
|
+
@stats[:failures] += 1
|
|
38
|
+
@stats[:errors_by_type]["NotFoundError"] ||= 0
|
|
39
|
+
@stats[:errors_by_type]["NotFoundError"] += 1
|
|
40
|
+
|
|
41
|
+
puts "❌ Model not found: #{e.message}"
|
|
42
|
+
puts " Suggestions: #{e.suggestions.join(', ')}" if e.suggestions && !e.suggestions.empty?
|
|
43
|
+
# Don't retry 404s
|
|
44
|
+
return { success: false, error: e, error_type: "NotFoundError", attempts: attempt }
|
|
45
|
+
rescue Ollama::HTTPError => e
|
|
46
|
+
@stats[:failures] += 1
|
|
47
|
+
@stats[:errors_by_type]["HTTPError"] ||= 0
|
|
48
|
+
@stats[:errors_by_type]["HTTPError"] += 1
|
|
49
|
+
|
|
50
|
+
puts "❌ HTTP Error (#{e.status_code}): #{e.message}"
|
|
51
|
+
if e.retryable?
|
|
52
|
+
@stats[:retries] += 1
|
|
53
|
+
puts " → Retryable, will retry..."
|
|
54
|
+
if attempt > max_attempts
|
|
55
|
+
return { success: false, error: e, error_type: "HTTPError", retryable: true,
|
|
56
|
+
attempts: attempt }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sleep(2**attempt) # Exponential backoff
|
|
60
|
+
next
|
|
61
|
+
else
|
|
62
|
+
puts " → Non-retryable, aborting"
|
|
63
|
+
return { success: false, error: e, error_type: "HTTPError", retryable: false, attempts: attempt }
|
|
64
|
+
end
|
|
65
|
+
rescue Ollama::TimeoutError => e
|
|
66
|
+
@stats[:failures] += 1
|
|
67
|
+
@stats[:errors_by_type]["TimeoutError"] ||= 0
|
|
68
|
+
@stats[:errors_by_type]["TimeoutError"] += 1
|
|
69
|
+
|
|
70
|
+
puts "⏱️ Timeout: #{e.message}"
|
|
71
|
+
@stats[:retries] += 1
|
|
72
|
+
return { success: false, error: e, error_type: "TimeoutError", attempts: attempt } unless attempt < max_attempts
|
|
73
|
+
|
|
74
|
+
puts " → Retrying with exponential backoff..."
|
|
75
|
+
sleep(2**attempt)
|
|
76
|
+
next
|
|
77
|
+
rescue Ollama::SchemaViolationError => e
|
|
78
|
+
@stats[:failures] += 1
|
|
79
|
+
@stats[:errors_by_type]["SchemaViolationError"] ||= 0
|
|
80
|
+
@stats[:errors_by_type]["SchemaViolationError"] += 1
|
|
81
|
+
|
|
82
|
+
puts "🔴 Schema violation: #{e.message}"
|
|
83
|
+
# Schema violations are usually not worth retrying (model issue)
|
|
84
|
+
# But we could try with a simpler schema as fallback
|
|
85
|
+
unless attempt < max_attempts
|
|
86
|
+
return { success: false, error: e, error_type: "SchemaViolationError", attempts: attempt }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
puts " → Attempting with simplified schema..."
|
|
90
|
+
simplified_schema = create_fallback_schema(schema)
|
|
91
|
+
return execute_with_resilience(
|
|
92
|
+
prompt: prompt,
|
|
93
|
+
schema: simplified_schema,
|
|
94
|
+
max_attempts: 1
|
|
95
|
+
)
|
|
96
|
+
rescue Ollama::InvalidJSONError => e
|
|
97
|
+
@stats[:failures] += 1
|
|
98
|
+
@stats[:errors_by_type]["InvalidJSONError"] ||= 0
|
|
99
|
+
@stats[:errors_by_type]["InvalidJSONError"] += 1
|
|
100
|
+
|
|
101
|
+
puts "📄 Invalid JSON: #{e.message}"
|
|
102
|
+
@stats[:retries] += 1
|
|
103
|
+
unless attempt < max_attempts
|
|
104
|
+
return { success: false, error: e, error_type: "InvalidJSONError", attempts: attempt }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
puts " → Retrying..."
|
|
108
|
+
sleep(1)
|
|
109
|
+
next
|
|
110
|
+
rescue Ollama::RetryExhaustedError => e
|
|
111
|
+
@stats[:failures] += 1
|
|
112
|
+
@stats[:errors_by_type]["RetryExhaustedError"] ||= 0
|
|
113
|
+
@stats[:errors_by_type]["RetryExhaustedError"] += 1
|
|
114
|
+
|
|
115
|
+
puts "🔄 Retries exhausted: #{e.message}"
|
|
116
|
+
return { success: false, error: e, error_type: "RetryExhaustedError", attempts: attempt }
|
|
117
|
+
rescue Ollama::Error => e
|
|
118
|
+
@stats[:failures] += 1
|
|
119
|
+
@stats[:errors_by_type]["Error"] ||= 0
|
|
120
|
+
@stats[:errors_by_type]["Error"] += 1
|
|
121
|
+
|
|
122
|
+
puts "❌ General error: #{e.message}"
|
|
123
|
+
return { success: false, error: e, error_type: "Error", attempts: attempt }
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
@stats[:failures] += 1
|
|
126
|
+
@stats[:errors_by_type]["StandardError"] ||= 0
|
|
127
|
+
@stats[:errors_by_type]["StandardError"] += 1
|
|
128
|
+
|
|
129
|
+
puts "💥 Unexpected error: #{e.class}: #{e.message}"
|
|
130
|
+
return { success: false, error: e, error_type: "StandardError", attempts: attempt }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
135
|
+
|
|
136
|
+
def create_fallback_schema(_original_schema)
|
|
137
|
+
# Create a minimal fallback schema
|
|
138
|
+
{
|
|
139
|
+
"type" => "object",
|
|
140
|
+
"additionalProperties" => true
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def display_stats
|
|
145
|
+
puts "\n" + "=" * 60
|
|
146
|
+
puts "Execution Statistics"
|
|
147
|
+
puts "=" * 60
|
|
148
|
+
puts "Total calls: #{@stats[:total_calls]}"
|
|
149
|
+
puts "Successes: #{@stats[:successes]}"
|
|
150
|
+
puts "Failures: #{@stats[:failures]}"
|
|
151
|
+
puts "Retries: #{@stats[:retries]}"
|
|
152
|
+
success_rate = @stats[:total_calls].positive? ? (@stats[:successes].to_f / @stats[:total_calls] * 100).round(2) : 0
|
|
153
|
+
puts "Success rate: #{success_rate}%"
|
|
154
|
+
puts "\nErrors by type:"
|
|
155
|
+
@stats[:errors_by_type].each do |type, count|
|
|
156
|
+
puts " #{type}: #{count}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Test scenarios
|
|
162
|
+
if __FILE__ == $PROGRAM_NAME
|
|
163
|
+
client = Ollama::Client.new
|
|
164
|
+
agent = ResilientAgent.new(client: client)
|
|
165
|
+
|
|
166
|
+
schema = {
|
|
167
|
+
"type" => "object",
|
|
168
|
+
"required" => ["status", "message"],
|
|
169
|
+
"properties" => {
|
|
170
|
+
"status" => { "type" => "string" },
|
|
171
|
+
"message" => { "type" => "string" }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
puts "=" * 60
|
|
176
|
+
puts "Test 1: Normal execution"
|
|
177
|
+
puts "=" * 60
|
|
178
|
+
result1 = agent.execute_with_resilience(
|
|
179
|
+
prompt: "Respond with status 'ok' and a greeting message",
|
|
180
|
+
schema: schema
|
|
181
|
+
)
|
|
182
|
+
puts "Result: #{result1[:success] ? 'SUCCESS' : 'FAILED'}"
|
|
183
|
+
|
|
184
|
+
puts "\n" + "=" * 60
|
|
185
|
+
puts "Test 2: Invalid model (should trigger NotFoundError)"
|
|
186
|
+
puts "=" * 60
|
|
187
|
+
# Temporarily use invalid model
|
|
188
|
+
invalid_client = Ollama::Client.new(
|
|
189
|
+
config: Ollama::Config.new.tap { |c| c.model = "nonexistent-model:999" }
|
|
190
|
+
)
|
|
191
|
+
invalid_agent = ResilientAgent.new(client: invalid_client)
|
|
192
|
+
result2 = invalid_agent.execute_with_resilience(
|
|
193
|
+
prompt: "Test",
|
|
194
|
+
schema: schema
|
|
195
|
+
)
|
|
196
|
+
puts "Result: #{result2[:success] ? 'SUCCESS' : 'FAILED'}"
|
|
197
|
+
|
|
198
|
+
agent.display_stats
|
|
199
|
+
end
|
|
200
|
+
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Advanced Example: Multi-Step Agent with Complex Decision Making
|
|
5
|
+
# Demonstrates: Nested schemas, state management, error recovery, confidence thresholds
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
require_relative "../lib/ollama_client"
|
|
9
|
+
|
|
10
|
+
class MultiStepAgent
|
|
11
|
+
def initialize(client:)
|
|
12
|
+
@client = client
|
|
13
|
+
@state = {
|
|
14
|
+
steps_completed: [],
|
|
15
|
+
data_collected: {},
|
|
16
|
+
errors: []
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# Complex nested schema for decision making
|
|
20
|
+
@decision_schema = {
|
|
21
|
+
"type" => "object",
|
|
22
|
+
"required" => ["step", "action", "reasoning", "confidence", "next_steps"],
|
|
23
|
+
"properties" => {
|
|
24
|
+
"step" => {
|
|
25
|
+
"type" => "integer",
|
|
26
|
+
"description" => "Current step number in the workflow"
|
|
27
|
+
},
|
|
28
|
+
"action" => {
|
|
29
|
+
"type" => "object",
|
|
30
|
+
"required" => ["type", "parameters"],
|
|
31
|
+
"properties" => {
|
|
32
|
+
"type" => {
|
|
33
|
+
"type" => "string",
|
|
34
|
+
"enum" => ["collect", "analyze", "transform", "validate", "complete"]
|
|
35
|
+
},
|
|
36
|
+
"parameters" => {
|
|
37
|
+
"type" => "object",
|
|
38
|
+
"additionalProperties" => true
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"reasoning" => {
|
|
43
|
+
"type" => "string",
|
|
44
|
+
"description" => "Why this action was chosen"
|
|
45
|
+
},
|
|
46
|
+
"confidence" => {
|
|
47
|
+
"type" => "number",
|
|
48
|
+
"minimum" => 0,
|
|
49
|
+
"maximum" => 1
|
|
50
|
+
},
|
|
51
|
+
"next_steps" => {
|
|
52
|
+
"type" => "array",
|
|
53
|
+
"items" => {
|
|
54
|
+
"type" => "string"
|
|
55
|
+
},
|
|
56
|
+
"minItems" => 0,
|
|
57
|
+
"maxItems" => 5
|
|
58
|
+
},
|
|
59
|
+
"risk_assessment" => {
|
|
60
|
+
"type" => "object",
|
|
61
|
+
"properties" => {
|
|
62
|
+
"level" => {
|
|
63
|
+
"type" => "string",
|
|
64
|
+
"enum" => ["low", "medium", "high"]
|
|
65
|
+
},
|
|
66
|
+
"factors" => {
|
|
67
|
+
"type" => "array",
|
|
68
|
+
"items" => { "type" => "string" }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def execute_workflow(goal:)
|
|
77
|
+
puts "🚀 Starting multi-step workflow"
|
|
78
|
+
puts "Goal: #{goal}\n\n"
|
|
79
|
+
|
|
80
|
+
max_steps = 10
|
|
81
|
+
step_count = 0
|
|
82
|
+
|
|
83
|
+
loop do
|
|
84
|
+
step_count += 1
|
|
85
|
+
break if step_count > max_steps
|
|
86
|
+
|
|
87
|
+
puts "─" * 60
|
|
88
|
+
puts "Step #{step_count}/#{max_steps}"
|
|
89
|
+
puts "─" * 60
|
|
90
|
+
|
|
91
|
+
context = build_context(goal: goal)
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
decision = @client.generate(
|
|
95
|
+
prompt: build_prompt(goal: goal, context: context),
|
|
96
|
+
schema: @decision_schema
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Validate confidence threshold
|
|
100
|
+
if decision["confidence"] < 0.5
|
|
101
|
+
puts "⚠️ Low confidence (#{(decision["confidence"] * 100).round}%) - requesting manual review"
|
|
102
|
+
break
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
display_decision(decision)
|
|
106
|
+
result = execute_action(decision)
|
|
107
|
+
|
|
108
|
+
# Update state
|
|
109
|
+
@state[:steps_completed] << {
|
|
110
|
+
step: decision["step"],
|
|
111
|
+
action: decision["action"]["type"],
|
|
112
|
+
result: result
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# Check if workflow is complete
|
|
116
|
+
if decision["action"]["type"] == "complete"
|
|
117
|
+
puts "\n✅ Workflow completed successfully!"
|
|
118
|
+
break
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Handle risk
|
|
122
|
+
if decision["risk_assessment"] && decision["risk_assessment"]["level"] == "high"
|
|
123
|
+
puts "⚠️ High risk detected - proceeding with caution"
|
|
124
|
+
end
|
|
125
|
+
rescue Ollama::SchemaViolationError => e
|
|
126
|
+
puts "❌ Schema violation: #{e.message}"
|
|
127
|
+
@state[:errors] << { step: step_count, error: "schema_violation", message: e.message }
|
|
128
|
+
break
|
|
129
|
+
rescue Ollama::RetryExhaustedError => e
|
|
130
|
+
puts "❌ Retries exhausted: #{e.message}"
|
|
131
|
+
@state[:errors] << { step: step_count, error: "retry_exhausted", message: e.message }
|
|
132
|
+
break
|
|
133
|
+
rescue Ollama::Error => e
|
|
134
|
+
puts "❌ Error: #{e.message}"
|
|
135
|
+
@state[:errors] << { step: step_count, error: "general", message: e.message }
|
|
136
|
+
# Try to recover or break
|
|
137
|
+
break if step_count > 3 # Don't loop forever
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
puts
|
|
141
|
+
sleep 0.5 # Small delay for readability
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
display_summary
|
|
145
|
+
@state
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def build_context(_goal:)
|
|
151
|
+
{
|
|
152
|
+
steps_completed: @state[:steps_completed].map { |s| s[:action] },
|
|
153
|
+
data_collected: @state[:data_collected].keys,
|
|
154
|
+
error_count: @state[:errors].length
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def build_prompt(goal:, context:)
|
|
159
|
+
<<~PROMPT
|
|
160
|
+
Goal: #{goal}
|
|
161
|
+
|
|
162
|
+
Workflow State:
|
|
163
|
+
- Steps completed: #{context[:steps_completed].join(", ") || "none"}
|
|
164
|
+
- Data collected: #{context[:data_collected].join(", ") || "none"}
|
|
165
|
+
- Errors encountered: #{context[:error_count]}
|
|
166
|
+
|
|
167
|
+
Analyze the current state and decide the next action.
|
|
168
|
+
Consider:
|
|
169
|
+
1. What data still needs to be collected?
|
|
170
|
+
2. What analysis is needed?
|
|
171
|
+
3. What validation is required?
|
|
172
|
+
4. When should the workflow complete?
|
|
173
|
+
|
|
174
|
+
Provide a structured decision with high confidence (>0.7) if possible.
|
|
175
|
+
PROMPT
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def display_decision(decision)
|
|
179
|
+
puts "\n📋 Decision:"
|
|
180
|
+
puts " Step: #{decision['step']}"
|
|
181
|
+
puts " Action: #{decision['action']['type']}"
|
|
182
|
+
puts " Reasoning: #{decision['reasoning']}"
|
|
183
|
+
puts " Confidence: #{(decision['confidence'] * 100).round}%"
|
|
184
|
+
if decision["risk_assessment"]
|
|
185
|
+
puts " Risk Level: #{decision['risk_assessment']['level']}"
|
|
186
|
+
if decision["risk_assessment"]["factors"]
|
|
187
|
+
puts " Risk Factors: #{decision['risk_assessment']['factors'].join(', ')}"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
return unless decision["next_steps"] && !decision["next_steps"].empty?
|
|
191
|
+
|
|
192
|
+
puts " Next Steps: #{decision['next_steps'].join(' → ')}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def execute_action(decision)
|
|
196
|
+
action_type = decision["action"]["type"]
|
|
197
|
+
params = decision["action"]["parameters"] || {}
|
|
198
|
+
|
|
199
|
+
case action_type
|
|
200
|
+
when "collect"
|
|
201
|
+
data_key = params["data_type"] || "unknown"
|
|
202
|
+
puts " 📥 Collecting: #{data_key}"
|
|
203
|
+
@state[:data_collected][data_key] = "collected_at_#{Time.now.to_i}"
|
|
204
|
+
{ status: "collected", key: data_key }
|
|
205
|
+
|
|
206
|
+
when "analyze"
|
|
207
|
+
target = params["target"] || "data"
|
|
208
|
+
puts " 🔍 Analyzing: #{target}"
|
|
209
|
+
{ status: "analyzed", target: target, insights: "analysis_complete" }
|
|
210
|
+
|
|
211
|
+
when "transform"
|
|
212
|
+
transformation = params["type"] || "default"
|
|
213
|
+
puts " 🔄 Transforming: #{transformation}"
|
|
214
|
+
{ status: "transformed", type: transformation }
|
|
215
|
+
|
|
216
|
+
when "validate"
|
|
217
|
+
validation_type = params["type"] || "general"
|
|
218
|
+
puts " ✓ Validating: #{validation_type}"
|
|
219
|
+
{ status: "validated", type: validation_type }
|
|
220
|
+
|
|
221
|
+
when "complete"
|
|
222
|
+
puts " ✅ Completing workflow"
|
|
223
|
+
{ status: "complete" }
|
|
224
|
+
|
|
225
|
+
else
|
|
226
|
+
{ status: "unknown_action" }
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def display_summary
|
|
231
|
+
puts "\n" + "=" * 60
|
|
232
|
+
puts "Workflow Summary"
|
|
233
|
+
puts "=" * 60
|
|
234
|
+
puts "Steps completed: #{@state[:steps_completed].length}"
|
|
235
|
+
puts "Data collected: #{@state[:data_collected].keys.join(', ') || 'none'}"
|
|
236
|
+
puts "Errors: #{@state[:errors].length}"
|
|
237
|
+
return unless @state[:errors].any?
|
|
238
|
+
|
|
239
|
+
puts "\nErrors:"
|
|
240
|
+
@state[:errors].each do |error|
|
|
241
|
+
puts " Step #{error[:step]}: #{error[:error]} - #{error[:message]}"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Run example
|
|
247
|
+
if __FILE__ == $PROGRAM_NAME
|
|
248
|
+
# Use longer timeout for multi-step workflows
|
|
249
|
+
config = Ollama::Config.new
|
|
250
|
+
config.timeout = 60 # 60 seconds for complex operations
|
|
251
|
+
client = Ollama::Client.new(config: config)
|
|
252
|
+
|
|
253
|
+
agent = MultiStepAgent.new(client: client)
|
|
254
|
+
agent.execute_workflow(
|
|
255
|
+
goal: "Collect user data, analyze patterns, validate results, and generate report"
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|