ollama-client 1.3.0 → 1.4.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 (178) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +11 -0
  3. data/API_CONTRACT.md +79 -8
  4. data/CHANGELOG.md +45 -0
  5. data/CONTRIBUTING.md +8 -0
  6. data/README.md +205 -273
  7. data/ROADMAP.md +23 -0
  8. data/docs/API_GAPS.md +18 -141
  9. data/docs/ARCHITECTURE.md +141 -0
  10. data/docs/AREAS_FOR_CONSIDERATION.md +13 -1
  11. data/docs/CLOUD.md +19 -0
  12. data/docs/CONSOLE_IMPROVEMENTS.md +20 -1
  13. data/docs/ECOSYSTEM_GEMS.md +41 -0
  14. data/docs/ECOSYSTEM_STRATEGY.md +151 -0
  15. data/docs/GETTING_STARTED.md +16 -8
  16. data/docs/INTEGRATION_TESTING.md +23 -5
  17. data/docs/PRODUCTION_FIXES.md +15 -3
  18. data/docs/QUICK_START.md +1 -1
  19. data/docs/README.md +1 -0
  20. data/docs/RUBYLLM_ADOPTION_MATRIX.md +273 -0
  21. data/docs/adr/001-openai-boundary.md +12 -0
  22. data/docs/adr/002-transport-abstraction.md +12 -0
  23. data/docs/adr/003-response-normalization.md +12 -0
  24. data/docs/adr/004-mock-transport.md +16 -0
  25. data/docs/adr/005-error-taxonomy.md +16 -0
  26. data/docs/adr/006-stream-runtime.md +16 -0
  27. data/docs/ecosystem/BOUNDARIES.md +19 -0
  28. data/docs/ecosystem/DEPENDENCY_GRAPH.md +14 -0
  29. data/docs/ecosystem/DESIGN_PRINCIPLES.md +9 -0
  30. data/docs/ecosystem/EXISTING_REPOS.md +31 -0
  31. data/docs/ecosystem/EXPERIMENTAL_LABS.md +24 -0
  32. data/docs/ecosystem/OVERVIEW.md +28 -0
  33. data/docs/ecosystem/RELEASE_ORDER.md +17 -0
  34. data/docs/observability/README.md +8 -0
  35. data/docs/rails/README.md +7 -0
  36. data/docs/rfcs/0001-stream-runtime.md +6 -0
  37. data/docs/rfcs/0002-schema-system.md +6 -0
  38. data/docs/rfcs/0003-observability-hooks.md +6 -0
  39. data/docs/rfcs/0004-async-runtime.md +6 -0
  40. data/docs/rfcs/README.md +16 -0
  41. data/docs/runtime/ERROR_CONTRACT.md +14 -0
  42. data/docs/runtime/SCHEMA_CONTRACT.md +28 -0
  43. data/docs/runtime/STREAM_CONTRACT.md +17 -0
  44. data/docs/runtime/STREAM_RUNTIME.md +20 -0
  45. data/docs/runtime/TRANSPORT_CONTRACT.md +26 -0
  46. data/docs/schema/README.md +8 -0
  47. data/docs/schema/STRUCTURED_OUTPUTS.md +22 -0
  48. data/docs/streaming/README.md +8 -0
  49. data/docs/testing/README.md +7 -0
  50. data/docs/testing/REPLAY_SYSTEM.md +17 -0
  51. data/docs/transport/README.md +7 -0
  52. data/examples/README.md +44 -0
  53. data/examples/basic_chat.rb +9 -0
  54. data/examples/cloud_models.rb +162 -0
  55. data/examples/embeddings.rb +10 -0
  56. data/examples/free_catalog.json +290 -0
  57. data/examples/generate.rb +9 -0
  58. data/examples/streaming.rb +12 -0
  59. data/examples/structured_tools.rb +90 -0
  60. data/examples/tool_calling_direct.rb +101 -0
  61. data/examples/tool_dto_example.rb +94 -0
  62. data/exe/ollama-client +5 -1
  63. data/lib/ollama/agent/executor.rb +243 -0
  64. data/lib/ollama/agent/messages.rb +31 -0
  65. data/lib/ollama/agent/planner.rb +45 -0
  66. data/lib/ollama/api_key_pool.rb +61 -0
  67. data/lib/ollama/attachment.rb +74 -0
  68. data/lib/ollama/capabilities.rb +1 -1
  69. data/lib/ollama/chat_response.rb +33 -0
  70. data/lib/ollama/client/chat/request_preparer.rb +82 -0
  71. data/lib/ollama/client/chat.rb +58 -68
  72. data/lib/ollama/client/chat_stream_processor.rb +85 -25
  73. data/lib/ollama/client/generate/request_preparer.rb +118 -0
  74. data/lib/ollama/client/generate/response_formatter.rb +95 -0
  75. data/lib/ollama/client/generate.rb +83 -165
  76. data/lib/ollama/client/model_management.rb +182 -84
  77. data/lib/ollama/client/openai_compat.rb +185 -0
  78. data/lib/ollama/client/raw.rb +66 -0
  79. data/lib/ollama/client/tool_intent.rb +38 -0
  80. data/lib/ollama/client/web_search.rb +39 -0
  81. data/lib/ollama/client.rb +83 -35
  82. data/lib/ollama/config.rb +122 -17
  83. data/lib/ollama/embeddings.rb +67 -29
  84. data/lib/ollama/errors.rb +55 -1
  85. data/lib/ollama/events.rb +55 -0
  86. data/lib/ollama/generate_stream_handler.rb +23 -4
  87. data/lib/ollama/http_error_handler.rb +42 -0
  88. data/lib/ollama/messages.rb +109 -0
  89. data/lib/ollama/middleware/cache.rb +73 -0
  90. data/lib/ollama/middleware/logger.rb +74 -0
  91. data/lib/ollama/middleware/metrics.rb +84 -0
  92. data/lib/ollama/middleware/tracing.rb +101 -0
  93. data/lib/ollama/middleware.rb +39 -0
  94. data/lib/ollama/model_profile.rb +1 -1
  95. data/lib/ollama/openai.rb +16 -0
  96. data/lib/ollama/options.rb +63 -1
  97. data/lib/ollama/params.rb +139 -0
  98. data/lib/ollama/parsers/base.rb +24 -0
  99. data/lib/ollama/parsers/chat.rb +22 -0
  100. data/lib/ollama/parsers/embeddings.rb +23 -0
  101. data/lib/ollama/parsers/generate.rb +38 -0
  102. data/lib/ollama/parsers/list_running.rb +15 -0
  103. data/lib/ollama/parsers/show_model.rb +14 -0
  104. data/lib/ollama/parsers/version.rb +15 -0
  105. data/lib/ollama/pipeline.rb +169 -0
  106. data/lib/ollama/plugins.rb +83 -0
  107. data/lib/ollama/policies/auto_pull.rb +69 -0
  108. data/lib/ollama/policies/base.rb +70 -0
  109. data/lib/ollama/policies/capability_validation.rb +88 -0
  110. data/lib/ollama/policies/fallback.rb +57 -0
  111. data/lib/ollama/policies/rate_limit.rb +115 -0
  112. data/lib/ollama/policies/repair_json.rb +137 -0
  113. data/lib/ollama/policies/retry/strategies/exponential.rb +27 -0
  114. data/lib/ollama/policies/retry/strategies/fixed.rb +27 -0
  115. data/lib/ollama/policies/retry/strategies/jitter.rb +30 -0
  116. data/lib/ollama/policies/retry/strategies/linear.rb +27 -0
  117. data/lib/ollama/policies/retry.rb +152 -0
  118. data/lib/ollama/policies/schema_repair.rb +180 -0
  119. data/lib/ollama/policies/timeout.rb +43 -0
  120. data/lib/ollama/policies.rb +24 -0
  121. data/lib/ollama/prompt.rb +86 -0
  122. data/lib/ollama/prompt_adapters/base.rb +3 -2
  123. data/lib/ollama/prompt_adapters/gemma4.rb +22 -14
  124. data/lib/ollama/prompts/tool_planner.rb +35 -0
  125. data/lib/ollama/providers/base.rb +70 -0
  126. data/lib/ollama/providers/llama_cpp.rb +131 -0
  127. data/lib/ollama/providers/ollama.rb +54 -0
  128. data/lib/ollama/providers/openai.rb +134 -0
  129. data/lib/ollama/providers.rb +28 -0
  130. data/lib/ollama/rate_limit_handler.rb +48 -0
  131. data/lib/ollama/request.rb +169 -0
  132. data/lib/ollama/response.rb +3 -2
  133. data/lib/ollama/responses/base.rb +11 -0
  134. data/lib/ollama/responses/chat.rb +11 -0
  135. data/lib/ollama/responses/embeddings.rb +14 -0
  136. data/lib/ollama/responses/generate.rb +22 -0
  137. data/lib/ollama/schema_dsl.rb +97 -0
  138. data/lib/ollama/schema_validator.rb +90 -53
  139. data/lib/ollama/schemas/tool_intent.json +15 -0
  140. data/lib/ollama/schemas/tool_intent.rb +16 -0
  141. data/lib/ollama/serializers/base.rb +44 -0
  142. data/lib/ollama/serializers/chat.rb +42 -0
  143. data/lib/ollama/serializers/embeddings.rb +36 -0
  144. data/lib/ollama/serializers/generate.rb +47 -0
  145. data/lib/ollama/streaming_observer.rb +22 -0
  146. data/lib/ollama/testing.rb +103 -0
  147. data/lib/ollama/tool/function/parameters/property.rb +72 -0
  148. data/lib/ollama/tool/function/parameters.rb +101 -0
  149. data/lib/ollama/tool/function.rb +78 -0
  150. data/lib/ollama/tool.rb +60 -0
  151. data/lib/ollama/tool_dsl.rb +93 -0
  152. data/lib/ollama/tool_intent.rb +19 -0
  153. data/lib/ollama/transport/base.rb +42 -0
  154. data/lib/ollama/transport/mock.rb +48 -0
  155. data/lib/ollama/transport/net_http.rb +76 -0
  156. data/lib/ollama/transport/request.rb +20 -0
  157. data/lib/ollama/transport/response.rb +41 -0
  158. data/lib/ollama/transport.rb +26 -0
  159. data/lib/ollama/version.rb +1 -1
  160. data/lib/ollama_client.rb +35 -0
  161. data/script/live_branch_smoke/chat_exercises.rb +232 -0
  162. data/script/live_branch_smoke/generation_exercises.rb +128 -0
  163. data/script/live_branch_smoke/model_exercises.rb +105 -0
  164. data/script/live_branch_smoke/utility_exercises.rb +375 -0
  165. data/script/live_branch_smoke.rb +159 -174
  166. data/test_all_features.rb +315 -0
  167. metadata +158 -24
  168. data/.cursor/.gitignore +0 -1
  169. data/RELEASE_NOTES_v0.2.6.md +0 -41
  170. data/devagent_proper.rb +0 -430
  171. data/docs/TESTING.md +0 -508
  172. data/examples/agent_loop.rb +0 -120
  173. data/examples/failure_modes/invalid_json_repair.rb +0 -42
  174. data/examples/production/rails_agent.rb +0 -62
  175. data/market.jpg +0 -0
  176. data/print_capabilities.rb +0 -20
  177. data/schema.json +0 -1
  178. data/test_tool.rb +0 -26
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example: Using Structured Tool Definitions
5
+ # Demonstrates explicit Tool classes for type-safe tool schemas
6
+
7
+ require "json"
8
+ require_relative "../lib/ollama_client"
9
+ require_relative "../lib/ollama/agent/executor"
10
+
11
+ puts "\n=== STRUCTURED TOOLS EXAMPLE ===\n"
12
+
13
+ client = Ollama::Client.new
14
+
15
+ # Define tools with explicit schemas using Tool classes
16
+ location_property = Ollama::Tool::Function::Parameters::Property.new(
17
+ type: "string",
18
+ description: "The city and state, e.g. San Francisco, CA"
19
+ )
20
+
21
+ unit_property = Ollama::Tool::Function::Parameters::Property.new(
22
+ type: "string",
23
+ description: "The unit to return temperature in",
24
+ enum: %w[celsius fahrenheit]
25
+ )
26
+
27
+ weather_parameters = Ollama::Tool::Function::Parameters.new(
28
+ type: "object",
29
+ properties: {
30
+ location: location_property,
31
+ unit: unit_property
32
+ },
33
+ required: %w[location unit]
34
+ )
35
+
36
+ weather_function = Ollama::Tool::Function.new(
37
+ name: "get_current_weather",
38
+ description: "Get the current weather for a location",
39
+ parameters: weather_parameters
40
+ )
41
+
42
+ weather_tool = Ollama::Tool.new(
43
+ type: "function",
44
+ function: weather_function
45
+ )
46
+
47
+ # Define the actual callable
48
+ weather_callable = lambda do |location:, unit:|
49
+ {
50
+ location: location,
51
+ unit: unit,
52
+ temperature: unit == "celsius" ? "22" : "72",
53
+ condition: "sunny"
54
+ }
55
+ end
56
+
57
+ # Use structured tool definition with callable
58
+ tools = {
59
+ "get_current_weather" => {
60
+ tool: weather_tool,
61
+ callable: weather_callable
62
+ }
63
+ }
64
+
65
+ # Alternative: Simple callable (auto-inferred schema)
66
+ # This still works for backward compatibility - just pass the callable directly
67
+ simple_tools = {
68
+ "get_time" => lambda do |timezone: "UTC"|
69
+ { timezone: timezone, time: Time.now.utc.iso8601 }
70
+ end
71
+ }
72
+
73
+ # Combine both approaches
74
+ # Simple callables can be passed directly (auto-inferred)
75
+ all_tools = tools.merge(simple_tools)
76
+
77
+ executor = Ollama::Agent::Executor.new(client, tools: all_tools)
78
+
79
+ begin
80
+ answer = executor.run(
81
+ system: "You are a helpful assistant. Use tools when needed.",
82
+ user: "What's the weather in Paris, France? Use celsius."
83
+ )
84
+
85
+ puts "\nAnswer: #{answer}"
86
+ rescue Ollama::Error => e
87
+ puts "Error: #{e.message}"
88
+ end
89
+
90
+ puts "\n=== DONE ===\n"
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example: Direct Tool Calling (matching ollama-ruby pattern)
5
+ # Demonstrates using Tool objects directly with chat()
6
+
7
+ require "json"
8
+ require_relative "../lib/ollama_client"
9
+ require_relative "../lib/ollama/agent/messages"
10
+
11
+ puts "\n=== DIRECT TOOL CALLING EXAMPLE ===\n"
12
+
13
+ client = Ollama::Client.new
14
+
15
+ # Define tool using Tool classes (matching ollama-ruby pattern)
16
+ tool = Ollama::Tool.new(
17
+ type: "function",
18
+ function: Ollama::Tool::Function.new(
19
+ name: "get_current_weather",
20
+ description: "Get the current weather for a location",
21
+ parameters: Ollama::Tool::Function::Parameters.new(
22
+ type: "object",
23
+ properties: {
24
+ location: Ollama::Tool::Function::Parameters::Property.new(
25
+ type: "string",
26
+ description: "The location to get the weather for, e.g. San Francisco, CA"
27
+ ),
28
+ temperature_unit: Ollama::Tool::Function::Parameters::Property.new(
29
+ type: "string",
30
+ description: "The unit to return the temperature in, either 'celsius' or 'fahrenheit'",
31
+ enum: %w[celsius fahrenheit]
32
+ )
33
+ },
34
+ required: %w[location temperature_unit]
35
+ )
36
+ )
37
+ )
38
+
39
+ # Create message
40
+ def message(location)
41
+ Ollama::Agent::Messages.user("What is the weather today in #{location}?")
42
+ end
43
+
44
+ puts "\n--- Using chat() to access tool_calls ---"
45
+
46
+ # chat() always returns a full Ollama::Response — message.tool_calls is
47
+ # populated whenever the model calls a tool. tools: takes an array.
48
+ response = client.chat(
49
+ model: "llama3.1:8b",
50
+ messages: [message("The City of Love")],
51
+ tools: [tool]
52
+ )
53
+
54
+ # Access tool_calls from response (using method access like ollama-ruby)
55
+ tool_calls = response.message&.tool_calls
56
+ if tool_calls && !tool_calls.empty?
57
+ puts "\nTool calls detected:"
58
+ tool_calls.each do |call|
59
+ name = call.name
60
+ args = call.arguments
61
+ puts " - #{name}: #{args}"
62
+ end
63
+ else
64
+ puts "\nNo tool calls in response"
65
+ puts "Response: #{response.message&.content}"
66
+ end
67
+
68
+ # You can also use hash access if preferred:
69
+ # tool_calls = response.to_h.dig("message", "tool_calls")
70
+
71
+ puts "\n--- Multiple tools (array) ---"
72
+
73
+ # You can also pass an array of tools
74
+ tool2 = Ollama::Tool.new(
75
+ type: "function",
76
+ function: Ollama::Tool::Function.new(
77
+ name: "get_time",
78
+ description: "Get the current time",
79
+ parameters: Ollama::Tool::Function::Parameters.new(
80
+ type: "object",
81
+ properties: {},
82
+ required: []
83
+ )
84
+ )
85
+ )
86
+
87
+ response2 = client.chat(
88
+ model: "llama3.1:8b",
89
+ messages: [Ollama::Agent::Messages.user("What time is it? Use the get_time tool.")],
90
+ tools: [tool, tool2] # Array of Tool objects
91
+ )
92
+
93
+ tool_calls2 = response2.message&.tool_calls
94
+ if tool_calls2 && !tool_calls2.empty?
95
+ puts "\nTool calls from multiple tools:"
96
+ tool_calls2.each do |call|
97
+ puts " - #{call.name}"
98
+ end
99
+ end
100
+
101
+ puts "\n=== DONE ===\n"
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example: Tool DTO (Data Transfer Object) functionality
5
+ # Demonstrates serialization, deserialization, and equality
6
+
7
+ require "json"
8
+ require_relative "../lib/ollama_client"
9
+
10
+ puts "\n=== TOOL DTO EXAMPLE ===\n"
11
+
12
+ # Create a tool definition
13
+ location_prop = Ollama::Tool::Function::Parameters::Property.new(
14
+ type: "string",
15
+ description: "The city name"
16
+ )
17
+
18
+ unit_prop = Ollama::Tool::Function::Parameters::Property.new(
19
+ type: "string",
20
+ description: "Temperature unit",
21
+ enum: %w[celsius fahrenheit]
22
+ )
23
+
24
+ params = Ollama::Tool::Function::Parameters.new(
25
+ type: "object",
26
+ properties: {
27
+ location: location_prop,
28
+ unit: unit_prop
29
+ },
30
+ required: %w[location unit]
31
+ )
32
+
33
+ function = Ollama::Tool::Function.new(
34
+ name: "get_weather",
35
+ description: "Get weather for a location",
36
+ parameters: params
37
+ )
38
+
39
+ tool = Ollama::Tool.new(type: "function", function: function)
40
+
41
+ # 1. Serialize to JSON
42
+ puts "\n--- Serialization ---"
43
+ json_str = tool.to_json
44
+ puts "JSON: #{json_str}"
45
+
46
+ # 2. Deserialize from hash
47
+ puts "\n--- Deserialization ---"
48
+ hash = JSON.parse(json_str)
49
+ deserialized_tool = Ollama::Tool.from_hash(hash)
50
+ puts "Deserialized tool name: #{deserialized_tool.function.name}"
51
+
52
+ # 3. Equality comparison
53
+ puts "\n--- Equality ---"
54
+ puts "Original == Deserialized: #{tool == deserialized_tool}"
55
+
56
+ # 4. Nested deserialization
57
+ puts "\n--- Nested Deserialization ---"
58
+ function_hash = {
59
+ "name" => "get_time",
60
+ "description" => "Get current time",
61
+ "parameters" => {
62
+ "type" => "object",
63
+ "properties" => {
64
+ "timezone" => {
65
+ "type" => "string",
66
+ "description" => "Timezone (e.g., UTC, EST)"
67
+ }
68
+ },
69
+ "required" => []
70
+ }
71
+ }
72
+
73
+ deserialized_function = Ollama::Tool::Function.from_hash(function_hash)
74
+ puts "Deserialized function: #{deserialized_function.name}"
75
+ puts "Parameters type: #{deserialized_function.parameters.type}"
76
+
77
+ # 5. Property deserialization
78
+ puts "\n--- Property Deserialization ---"
79
+ prop_hash = {
80
+ "type" => "string",
81
+ "description" => "City name",
82
+ "enum" => %w[paris london tokyo]
83
+ }
84
+
85
+ deserialized_prop = Ollama::Tool::Function::Parameters::Property.from_hash(prop_hash)
86
+ puts "Property type: #{deserialized_prop.type}"
87
+ puts "Property enum: #{deserialized_prop.enum.inspect}"
88
+
89
+ # 6. Empty check
90
+ puts "\n--- Empty Check ---"
91
+ empty_params = Ollama::Tool::Function::Parameters.new(type: "object", properties: {}, required: [])
92
+ puts "Empty params? #{empty_params.empty?}"
93
+
94
+ puts "\n=== DONE ===\n"
data/exe/ollama-client CHANGED
@@ -163,7 +163,9 @@ module OllamaCLI
163
163
  def self.build_client(model = nil)
164
164
  config = Ollama::Config.new
165
165
  config.base_url = ENV["OLLAMA_BASE_URL"] if ENV["OLLAMA_BASE_URL"]
166
- config.api_key = ENV["OLLAMA_API_KEY"] if ENV["OLLAMA_API_KEY"]
166
+ config.api_keys = ENV.fetch("OLLAMA_API_KEYS", nil) if ENV.fetch("OLLAMA_API_KEYS", nil)
167
+ config.api_key = ENV.fetch("OLLAMA_API_KEY", nil) if config.api_keys.empty? && ENV.fetch("OLLAMA_API_KEY", nil)
168
+ config.enable_multi_key_concurrency = Ollama::Config.truthy_env?(ENV.fetch("ENABLE_MULTI_KEY_CONCURRENCY", nil))
167
169
  config.model = model if model
168
170
  Ollama::Client.new(config: config)
169
171
  end
@@ -201,6 +203,8 @@ module OllamaCLI
201
203
  Environment:
202
204
  OLLAMA_BASE_URL Override base URL (default: http://localhost:11434)
203
205
  OLLAMA_API_KEY Optional Bearer token for Ollama Cloud (https://ollama.com)
206
+ OLLAMA_API_KEYS Optional comma-separated Bearer token pool for 429 failover
207
+ ENABLE_MULTI_KEY_CONCURRENCY Set true/1 to distribute initial keys across threads
204
208
  USAGE
205
209
  end
206
210
  end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "messages"
5
+
6
+ module Ollama
7
+ module Agent
8
+ # Stateful executor-style agent using /api/chat + tool-calling loop.
9
+ #
10
+ # The LLM never executes tools. It can only request tool calls; this class
11
+ # executes Ruby callables and feeds results back as role: "tool" messages.
12
+ class Executor
13
+ attr_reader :messages
14
+
15
+ def initialize(client, tools:, max_steps: 20, stream: nil)
16
+ @client = client
17
+ @tools = tools || {}
18
+ @max_steps = max_steps
19
+ @stream = stream
20
+ @messages = []
21
+ end
22
+
23
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
24
+ def run(system:, user:)
25
+ @messages = [
26
+ Messages.system(system),
27
+ Messages.user(user)
28
+ ]
29
+
30
+ last_assistant_content = nil
31
+
32
+ @max_steps.times do
33
+ @stream&.emit(:state, state: :assistant_streaming)
34
+
35
+ response = request_chat
36
+
37
+ message = response.message
38
+ content = message&.content
39
+ tool_calls = message&.tool_calls || []
40
+
41
+ # Preserve the assistant turn in history (including tool_calls if present).
42
+ @messages << Messages.assistant(content.to_s, tool_calls: tool_calls.map(&:to_h)) if content || !tool_calls.empty?
43
+ last_assistant_content = content if content && !content.empty?
44
+
45
+ break if tool_calls.empty?
46
+
47
+ tool_calls.each do |call|
48
+ name = call.name
49
+ raise Ollama::Error, "Tool call missing function name: #{call.inspect}" if name.nil? || name.empty?
50
+
51
+ args_hash = call.arguments.is_a?(Hash) ? call.arguments : {}
52
+
53
+ tool_entry = @tools[name] || @tools[name.to_sym]
54
+ raise Ollama::Error, "Tool '#{name}' not found. Available: #{@tools.keys.sort.join(", ")}" unless tool_entry
55
+
56
+ # Extract callable from tool entry
57
+ callable = extract_callable(tool_entry)
58
+ raise Ollama::Error, "Tool '#{name}' has no associated callable" unless callable
59
+
60
+ @stream&.emit(:state, state: :tool_executing)
61
+ result = invoke_tool(callable, args_hash)
62
+ tool_content = encode_tool_result(result)
63
+
64
+ tool_call_id = call.id
65
+ @messages << Messages.tool(content: tool_content, name: name, tool_call_id: tool_call_id)
66
+ @stream&.emit(:state, state: :tool_result_injected)
67
+ end
68
+ end
69
+
70
+ if last_assistant_content.nil?
71
+ raise Ollama::Error,
72
+ "Executor exceeded max_steps=#{@max_steps} (possible infinite tool loop)"
73
+ end
74
+
75
+ @stream&.emit(:final, text: last_assistant_content.to_s)
76
+ last_assistant_content
77
+ end
78
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
79
+
80
+ private
81
+
82
+ def request_chat
83
+ hooks = streaming_hooks
84
+ @client.chat(messages: @messages, tools: tool_definitions, stream: !hooks.nil?, hooks: hooks || {})
85
+ end
86
+
87
+ def streaming_hooks
88
+ return nil unless @stream
89
+
90
+ {
91
+ on_token: lambda { |delta, _logprobs = nil|
92
+ @stream.emit(:token, text: delta.to_s) if delta && !delta.to_s.empty?
93
+ },
94
+ on_tool_call: lambda { |call|
95
+ name = dig(call, %w[function name]) || call["name"]
96
+ @stream.emit(:tool_call_detected, name: name, data: call) if name
97
+ }
98
+ }
99
+ end
100
+
101
+ def tool_definitions
102
+ @tools.keys.sort.map do |key|
103
+ name = key.to_s
104
+ tool_entry = @tools[key]
105
+
106
+ # Support both explicit Tool objects and callables
107
+ # Tool objects are schema definitions only
108
+ if tool_entry.is_a?(Ollama::Tool)
109
+ tool_entry.to_h
110
+ elsif tool_entry.is_a?(Hash) && tool_entry[:tool].is_a?(Ollama::Tool)
111
+ # Format: { tool: Tool, callable: proc }
112
+ tool_entry[:tool].to_h
113
+ else
114
+ # Auto-infer from callable signature (default)
115
+ callable = tool_entry.is_a?(Hash) ? tool_entry[:callable] : tool_entry
116
+ parameters = infer_parameters(callable)
117
+ {
118
+ type: "function",
119
+ function: {
120
+ name: name,
121
+ description: "Tool: #{name}",
122
+ parameters: parameters
123
+ }
124
+ }
125
+ end
126
+ end
127
+ end
128
+
129
+ def infer_parameters(callable)
130
+ return { "type" => "object", "additionalProperties" => true } unless callable.respond_to?(:parameters)
131
+
132
+ params = callable.parameters
133
+ return { "type" => "object", "additionalProperties" => true } if params.empty?
134
+
135
+ properties = {}
136
+ required = []
137
+
138
+ params.each do |type, name|
139
+ next unless name # Skip anonymous parameters
140
+
141
+ param_name = name.to_s
142
+ properties[param_name] = { "type" => "string", "description" => "Parameter: #{param_name}" }
143
+
144
+ # Required if it's a required keyword argument (:keyreq) or required positional (:req)
145
+ required << param_name if %i[keyreq req].include?(type)
146
+ end
147
+
148
+ schema = { "type" => "object" }
149
+ schema["properties"] = properties unless properties.empty?
150
+ schema["required"] = required unless required.empty?
151
+ schema["additionalProperties"] = false if properties.any?
152
+
153
+ schema
154
+ end
155
+
156
+ def dig(obj, path)
157
+ cur = obj
158
+ path.each do |k|
159
+ return nil unless cur.is_a?(Hash)
160
+
161
+ cur = cur[k] || cur[k.to_sym]
162
+ end
163
+ cur
164
+ end
165
+
166
+ def invoke_tool(callable, args_hash)
167
+ sym_args = normalize_parameter_names(args_hash)
168
+ keyword_result = call_with_keywords(callable, sym_args)
169
+ return keyword_result[:value] if keyword_result[:success]
170
+
171
+ call_with_positional(callable, args_hash)
172
+ end
173
+
174
+ def normalize_parameter_names(args_hash)
175
+ args_hash.transform_keys { |k| k.to_s.to_sym }
176
+ end
177
+
178
+ def apply_parameter_aliases(args, callable)
179
+ return args unless callable.respond_to?(:parameters)
180
+
181
+ param_names = callable.parameters.map { |_type, name| name }
182
+ aliased = args.dup
183
+
184
+ # Common aliases: directory -> path, file -> path, filename -> path
185
+ if param_names.include?(:path) && !aliased.key?(:path)
186
+ if aliased.key?(:directory)
187
+ aliased[:path] = aliased.delete(:directory)
188
+ elsif aliased.key?(:file)
189
+ aliased[:path] = aliased.delete(:file)
190
+ elsif aliased.key?(:filename)
191
+ aliased[:path] = aliased.delete(:filename)
192
+ end
193
+ end
194
+
195
+ aliased
196
+ end
197
+
198
+ def call_with_keywords(callable, sym_args)
199
+ { success: true, value: callable.call(**sym_args) }
200
+ rescue ArgumentError => e
201
+ return { success: false } unless missing_keyword_error?(e)
202
+
203
+ aliased_args = apply_parameter_aliases(sym_args, callable)
204
+ return { success: false } if aliased_args == sym_args
205
+
206
+ begin
207
+ { success: true, value: callable.call(**aliased_args) }
208
+ rescue ArgumentError
209
+ { success: false }
210
+ end
211
+ end
212
+
213
+ def call_with_positional(callable, args_hash)
214
+ callable.call(args_hash)
215
+ rescue ArgumentError => e
216
+ raise ArgumentError,
217
+ "Tool invocation failed: #{e.message}. Arguments provided: #{args_hash.inspect}. " \
218
+ "Ensure the tool call includes all required parameters."
219
+ end
220
+
221
+ def missing_keyword_error?(error)
222
+ error.message.include?("required keyword") || error.message.include?("missing keyword")
223
+ end
224
+
225
+ def encode_tool_result(result)
226
+ return result if result.is_a?(String)
227
+
228
+ JSON.generate(result)
229
+ rescue JSON::GeneratorError
230
+ result.to_s
231
+ end
232
+
233
+ def extract_callable(tool_entry)
234
+ case tool_entry
235
+ when Proc, Method
236
+ tool_entry
237
+ when Hash
238
+ tool_entry[:callable] || tool_entry["callable"]
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ollama
4
+ module Agent
5
+ # Small helpers for building chat message hashes.
6
+ module Messages
7
+ def self.system(content)
8
+ { role: "system", content: content.to_s }
9
+ end
10
+
11
+ def self.user(content)
12
+ { role: "user", content: content.to_s }
13
+ end
14
+
15
+ def self.assistant(content, tool_calls: nil)
16
+ msg = { role: "assistant", content: content.to_s }
17
+ msg[:tool_calls] = tool_calls if tool_calls
18
+ msg
19
+ end
20
+
21
+ # Tool results are sent back as role: "tool".
22
+ # Some APIs require `tool_call_id` to associate results with calls.
23
+ def self.tool(content:, name: nil, tool_call_id: nil)
24
+ msg = { role: "tool", content: content.to_s }
25
+ msg[:name] = name if name
26
+ msg[:tool_call_id] = tool_call_id if tool_call_id
27
+ msg
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "messages"
5
+
6
+ module Ollama
7
+ module Agent
8
+ # Stateless planner-style agent using /api/generate.
9
+ #
10
+ # Intended for planning, classification, routing, and deterministic structured outputs.
11
+ class Planner
12
+ ANY_JSON_SCHEMA = {
13
+ "anyOf" => [
14
+ { "type" => "object", "additionalProperties" => true },
15
+ { "type" => "array" },
16
+ { "type" => "string" },
17
+ { "type" => "number" },
18
+ { "type" => "integer" },
19
+ { "type" => "boolean" },
20
+ { "type" => "null" }
21
+ ]
22
+ }.freeze
23
+
24
+ def initialize(client)
25
+ @client = client
26
+ end
27
+
28
+ # @param prompt [String]
29
+ # @param context [Hash, nil]
30
+ # @param schema [Hash, nil]
31
+ # @return [Object] Parsed JSON (Hash/Array/String/Number/Boolean/Nil)
32
+ def run(prompt:, context: nil, schema: nil)
33
+ full_prompt = prompt.to_s
34
+
35
+ full_prompt = "#{full_prompt}\n\nContext (JSON):\n#{JSON.pretty_generate(context)}" if context && !context.empty?
36
+
37
+ @client.generate(
38
+ prompt: full_prompt,
39
+ schema: schema || ANY_JSON_SCHEMA,
40
+ strict: true
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ollama
4
+ # Thread-safe immutable API key pool for rate-limit failover.
5
+ #
6
+ # The pool never mutates the configured key list after construction. Request
7
+ # state is represented as a short-lived context hash so retry counters and
8
+ # rotation offsets remain isolated to the caller's execution path.
9
+ class ApiKeyPool
10
+ attr_reader :keys
11
+
12
+ # @param keys [Array<String>] API keys in configured priority order
13
+ # @param concurrency_enabled [Boolean] whether initial keys should be distributed round-robin
14
+ def initialize(keys, concurrency_enabled: false)
15
+ @keys = keys.map(&:to_s).map(&:strip).reject(&:empty?).freeze
16
+ @concurrency_enabled = concurrency_enabled
17
+ @mutex = Mutex.new
18
+ @next_index = 0
19
+ end
20
+
21
+ # @return [Integer] number of configured keys
22
+ def size
23
+ @keys.size
24
+ end
25
+
26
+ # @return [Boolean] true when no keys are configured
27
+ def empty?
28
+ @keys.empty?
29
+ end
30
+
31
+ # Build request-local rotation context.
32
+ #
33
+ # @return [Hash] isolated context for one logical request attempt
34
+ def request_context
35
+ { start_index: initial_index }
36
+ end
37
+
38
+ # Resolve a key from a request-local context and rotation offset.
39
+ #
40
+ # @param context [Hash]
41
+ # @param offset [Integer]
42
+ # @return [String, nil]
43
+ def key_for(context, offset: 0)
44
+ return nil if empty?
45
+
46
+ @keys[(context.fetch(:start_index) + offset) % size]
47
+ end
48
+
49
+ private
50
+
51
+ def initial_index
52
+ return 0 unless @concurrency_enabled
53
+
54
+ @mutex.synchronize do
55
+ current = @next_index
56
+ @next_index = (@next_index + 1) % size if size.positive?
57
+ current
58
+ end
59
+ end
60
+ end
61
+ end