active_harness 0.3.7 → 0.3.9

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_harness/agent/goal_check.rb +26 -0
  3. data/lib/active_harness/agent/state.rb +90 -0
  4. data/lib/active_harness/agent.rb +734 -0
  5. data/lib/active_harness/core/errors.rb +11 -0
  6. data/lib/active_harness/http/client.rb +58 -11
  7. data/lib/active_harness/http/streaming_client.rb +33 -1
  8. data/lib/active_harness/pipeline/hooks.rb +1 -1
  9. data/lib/active_harness/providers/anthropic.rb +57 -8
  10. data/lib/active_harness/providers/base.rb +6 -0
  11. data/lib/active_harness/providers/deepseek.rb +7 -4
  12. data/lib/active_harness/providers/gpustack.rb +7 -4
  13. data/lib/active_harness/providers/groq.rb +7 -4
  14. data/lib/active_harness/providers/hetzner.rb +7 -4
  15. data/lib/active_harness/providers/mistral.rb +7 -4
  16. data/lib/active_harness/providers/ollama.rb +7 -4
  17. data/lib/active_harness/providers/ollama_cloud.rb +7 -4
  18. data/lib/active_harness/providers/openai.rb +7 -4
  19. data/lib/active_harness/providers/openrouter.rb +7 -4
  20. data/lib/active_harness/providers/tool_calling.rb +95 -0
  21. data/lib/active_harness/providers/xai.rb +7 -4
  22. data/lib/active_harness/railtie.rb +1 -1
  23. data/lib/active_harness/request/hooks.rb +1 -1
  24. data/lib/active_harness/request/providers.rb +15 -0
  25. data/lib/active_harness/request.rb +39 -7
  26. data/lib/active_harness/result.rb +4 -0
  27. data/lib/active_harness/tool/path_policy.rb +28 -0
  28. data/lib/active_harness/tool.rb +348 -0
  29. data/lib/active_harness/tribunal/hooks.rb +1 -1
  30. data/lib/active_harness.rb +4 -1
  31. data/lib/generators/active_harness/agent/agent_generator.rb +16 -0
  32. data/lib/generators/active_harness/agent/templates/agent.rb.tt +14 -0
  33. data/lib/generators/active_harness/goal/goal_generator.rb +16 -0
  34. data/lib/generators/active_harness/goal/templates/goal.rb.tt +14 -0
  35. data/lib/generators/active_harness/install/install_generator.rb +30 -25
  36. data/lib/generators/active_harness/install/templates/agents/files_agent.rb +39 -0
  37. data/lib/generators/active_harness/install/templates/concerns/agent_tracing.rb +36 -0
  38. data/lib/generators/active_harness/install/templates/concerns/pipeline_tracing.rb +28 -0
  39. data/lib/generators/active_harness/install/templates/concerns/request_tracing.rb +44 -0
  40. data/lib/generators/active_harness/install/templates/concerns/tool_tracing.rb +21 -0
  41. data/lib/generators/active_harness/install/templates/concerns/tracing_log.rb +18 -0
  42. data/lib/generators/active_harness/install/templates/concerns/tribunal_tracing.rb +28 -0
  43. data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +23 -2
  44. data/lib/generators/active_harness/install/templates/goals/answer_present_goal.rb +22 -0
  45. data/lib/generators/active_harness/install/templates/pipelines/support_pipeline.rb +8 -18
  46. data/lib/generators/active_harness/install/templates/prompts/files_prompt.rb +9 -0
  47. data/lib/generators/active_harness/install/templates/prompts/support_prompt.rb +3 -0
  48. data/lib/generators/active_harness/install/templates/requests/files_request.rb +18 -0
  49. data/lib/generators/active_harness/install/templates/requests/support_guard_request.rb +12 -0
  50. data/lib/generators/active_harness/install/templates/requests/support_request.rb +6 -0
  51. data/lib/generators/active_harness/install/templates/tools/ls_tool.rb +40 -0
  52. data/lib/generators/active_harness/install/templates/tools/read_file_tool.rb +43 -0
  53. data/lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb +7 -0
  54. data/lib/generators/active_harness/pipeline/templates/pipeline.rb.tt +5 -0
  55. data/lib/generators/active_harness/request/templates/request.rb.tt +2 -0
  56. data/lib/generators/active_harness/tool/templates/tool.rb.tt +22 -0
  57. data/lib/generators/active_harness/tool/tool_generator.rb +16 -0
  58. data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +3 -0
  59. metadata +31 -7
@@ -1,4 +1,15 @@
1
1
  module ActiveHarness
2
+ # Matches a client that went away mid-stream: IOError anywhere, plus
3
+ # ActionController::Live::ClientDisconnected when Rails is loaded.
4
+ # Usable in `rescue ActiveHarness::ClientGone`; never raises NameError outside Rails.
5
+ module ClientGone
6
+ def self.===(error)
7
+ return true if IOError === error
8
+ defined?(::ActionController::Live::ClientDisconnected) &&
9
+ ::ActionController::Live::ClientDisconnected === error
10
+ end
11
+ end
12
+
2
13
  module Errors
3
14
  Error = Class.new(StandardError)
4
15
 
@@ -4,35 +4,82 @@ module ActiveHarness
4
4
  module Http
5
5
  # Thin Net::HTTP wrapper — no external dependencies.
6
6
  class Client
7
- # @param url [URI]
7
+ METHODS = {
8
+ get: Net::HTTP::Get,
9
+ post: Net::HTTP::Post,
10
+ put: Net::HTTP::Put,
11
+ patch: Net::HTTP::Patch,
12
+ delete: Net::HTTP::Delete
13
+ }.freeze
14
+
15
+ # @param method [Symbol] :get, :post, :put, :patch or :delete
16
+ # @param url [URI] https (default) or http — TLS follows the URL scheme
8
17
  # @param headers [Hash{String => String}]
9
- # @param body [String] JSON-serialized body
18
+ # @param body [String, nil] request body (usually JSON-serialized); omit for GET/DELETE
10
19
  # @param timeout [Integer] seconds (open + read)
11
20
  # @return [String] raw response body; also responds to #http_status
12
21
  # (Integer) so providers can classify errors by
13
22
  # status without changing the return type
23
+ def request(method, url, headers: {}, body: nil, timeout: 30)
24
+ klass = METHODS[method.to_sym] or raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
25
+
26
+ begin
27
+ res = build_http(url, timeout).request(build_request(klass, url, headers, body))
28
+ rescue Net::OpenTimeout, Net::ReadTimeout
29
+ raise Errors::TimeoutError, "Request to #{url.host} timed out"
30
+ rescue => e
31
+ raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
32
+ end
33
+
34
+ with_http_status(res)
35
+ end
36
+
14
37
  def post(url, headers:, body:, timeout: 30)
38
+ request(:post, url, headers: headers, body: body, timeout: timeout)
39
+ end
40
+
41
+ def get(url, headers: {}, timeout: 30)
42
+ request(:get, url, headers: headers, timeout: timeout)
43
+ end
44
+
45
+ def put(url, headers:, body:, timeout: 30)
46
+ request(:put, url, headers: headers, body: body, timeout: timeout)
47
+ end
48
+
49
+ def patch(url, headers:, body:, timeout: 30)
50
+ request(:patch, url, headers: headers, body: body, timeout: timeout)
51
+ end
52
+
53
+ def delete(url, headers: {}, timeout: 30)
54
+ request(:delete, url, headers: headers, timeout: timeout)
55
+ end
56
+
57
+ private
58
+
59
+ def build_http(url, timeout)
15
60
  http = Net::HTTP.new(url.host, url.port)
16
- http.use_ssl = true
61
+ http.use_ssl = url.scheme == "https"
17
62
  http.open_timeout = timeout
18
63
  http.read_timeout = timeout
64
+ http
65
+ end
19
66
 
20
- req = Net::HTTP::Post.new(url)
67
+ def build_request(klass, url, headers, body)
68
+ req = klass.new(url)
21
69
  headers.each { |k, v| req[k] = v }
22
- req.body = body
70
+ req.body = body if body
71
+ req
72
+ end
23
73
 
24
- res = http.request(req)
74
+ # Returns the response body with an #http_status accessor attached.
75
+ def with_http_status(res)
25
76
  body = res.body
26
77
  return body if body.nil?
27
78
 
28
- body = body.dup
79
+ body = body.dup
29
80
  status = res.code.to_i
30
81
  body.define_singleton_method(:http_status) { status }
31
82
  body
32
- rescue Net::OpenTimeout, Net::ReadTimeout
33
- raise Errors::TimeoutError, "Request to #{url.host} timed out"
34
- rescue => e
35
- raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
36
83
  end
37
84
  end
38
85
  end
@@ -18,7 +18,7 @@ module ActiveHarness
18
18
  # @return [Hash] { content: String, usage: Hash|nil }
19
19
  def post(url, headers:, body:, timeout: 60, on_token:, parse_chunk: nil)
20
20
  http = Net::HTTP.new(url.host, url.port)
21
- http.use_ssl = true
21
+ http.use_ssl = url.scheme == "https"
22
22
  http.open_timeout = timeout
23
23
  http.read_timeout = timeout
24
24
 
@@ -31,6 +31,10 @@ module ActiveHarness
31
31
  usage = {}
32
32
 
33
33
  http.request(req) do |response|
34
+ # Non-2xx: the body is a plain JSON error, not SSE — read it whole and
35
+ # raise instead of silently returning an empty stream.
36
+ raise_for_status!(response.code.to_i, response.read_body) unless response.is_a?(Net::HTTPSuccess)
37
+
34
38
  response.read_body do |chunk|
35
39
  buffer += chunk
36
40
  while (line_end = buffer.index("\n"))
@@ -55,12 +59,40 @@ module ActiveHarness
55
59
  { content: content, usage: usage.empty? ? nil : usage }
56
60
  rescue Net::OpenTimeout, Net::ReadTimeout
57
61
  raise Errors::TimeoutError, "Request to #{url.host} timed out"
62
+ rescue Errors::ProviderError
63
+ raise
58
64
  rescue => e
59
65
  raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
60
66
  end
61
67
 
62
68
  private
63
69
 
70
+ # Maps an HTTP error status + body to the same error classes the
71
+ # non-streaming providers raise. The message is pulled from the common
72
+ # error shapes ({error:{message}}, {message}, {detail}), else the raw body.
73
+ def raise_for_status!(status, body)
74
+ msg = error_message(body)
75
+ code = status.to_s
76
+
77
+ case status
78
+ when 401, 403 then raise Errors::InvalidApiKeyError.new(msg, error_code: code)
79
+ when 429 then raise Errors::RateLimitError.new(msg, error_code: code)
80
+ when 503 then raise Errors::ProviderUnavailableError.new(msg, error_code: code)
81
+ when 500..599 then raise Errors::ServerError.new(msg, error_code: code)
82
+ else raise Errors::InvalidRequestError.new(msg, error_code: code)
83
+ end
84
+ end
85
+
86
+ def error_message(body)
87
+ data = JSON.parse(body.to_s)
88
+ msg = data.dig("error", "message") if data["error"].is_a?(Hash)
89
+ msg ||= data["error"] if data["error"].is_a?(String)
90
+ msg ||= data["message"] || data["detail"]
91
+ (msg.is_a?(String) ? msg : data.to_json)
92
+ rescue JSON::ParserError, NoMethodError, TypeError
93
+ body.to_s.strip.empty? ? "HTTP error with empty body" : body.to_s.strip[0, 500]
94
+ end
95
+
64
96
  def default_chunk(parsed)
65
97
  token = parsed.dig("choices", 0, "delta", "content")
66
98
  raw_u = parsed["usage"]
@@ -63,7 +63,7 @@ module ActiveHarness
63
63
  def fire(event, step_name, data, config)
64
64
  run_hooks(config[:hooks], event, step_name, data)
65
65
  @stream&.call(:pipeline, event, step_name, data)
66
- rescue IOError, ActionController::Live::ClientDisconnected
66
+ rescue ClientGone
67
67
  nil
68
68
  end
69
69
 
@@ -8,7 +8,11 @@ module ActiveHarness
8
8
  ANTHROPIC_VERSION = "2023-06-01"
9
9
  DEFAULT_MAX_TOKENS = 1024
10
10
 
11
- def call(model:, messages:, temperature: 0.7, stream: nil)
11
+ def supports_tools?
12
+ true
13
+ end
14
+
15
+ def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
12
16
  system_msg, chat_messages = extract_system(messages)
13
17
 
14
18
  body = {
@@ -18,6 +22,7 @@ module ActiveHarness
18
22
  messages: chat_messages
19
23
  }
20
24
  body[:system] = system_msg if system_msg
25
+ body[:tools] = anthropic_tools(tools) if tools && !tools.empty?
21
26
 
22
27
  headers = {
23
28
  "Content-Type" => "application/json",
@@ -32,30 +37,74 @@ module ActiveHarness
32
37
  stream: stream,
33
38
  provider: :anthropic,
34
39
  model: model
35
- ) if stream
40
+ ) if stream && !(tools && !tools.empty?)
36
41
 
37
42
  raw = post_json(URI(config.anthropic_api_url), headers: headers, body: body)
38
43
  data = parse!(raw)
39
44
  handle_error!(data)
40
45
 
41
46
  {
42
- content: data.dig("content", 0, "text").to_s.strip,
47
+ content: Array(data["content"]).select { |b| b["type"] == "text" }.map { |b| b["text"] }.join.strip,
43
48
  provider: :anthropic,
44
49
  model: data["model"] || model,
45
- usage: extract_usage_anthropic(data)
50
+ usage: extract_usage_anthropic(data),
51
+ **anthropic_tool_calls_entry(data)
46
52
  }
47
53
  end
48
54
 
49
55
  private
50
56
 
51
57
  # Anthropic keeps system prompt separate from the messages array.
58
+ # Also maps the neutral tool-calling message shape to Anthropic content blocks.
52
59
  def extract_system(messages)
53
- system = messages.find { |m| m[:role] == "system" || m["role"] == "system" }
54
- chat = messages.reject { |m| m[:role] == "system" || m["role"] == "system" }
55
- .map { |m| { role: m[:role] || m["role"], content: m[:content] || m["content"] } }
60
+ role_of = ->(m) { (m[:role] || m["role"]).to_s }
61
+ system = messages.find { |m| role_of.call(m) == "system" }
62
+ chat = messages.reject { |m| role_of.call(m) == "system" }.map { |m| anthropic_message(m) }
56
63
 
57
64
  system_text = system && (system[:content] || system["content"])
58
- [system_text, chat]
65
+ [system_text, merge_tool_results(chat)]
66
+ end
67
+
68
+ def anthropic_message(m)
69
+ role = (m[:role] || m["role"]).to_s
70
+ content = m[:content] || m["content"]
71
+ calls = m[:tool_calls] || m["tool_calls"]
72
+
73
+ if role == "assistant" && calls && !calls.empty?
74
+ blocks = []
75
+ blocks << { type: "text", text: content.to_s } unless content.to_s.empty?
76
+ calls.each { |c| blocks << { type: "tool_use", id: c[:id], name: c[:name], input: c[:arguments] || {} } }
77
+ { role: "assistant", content: blocks }
78
+ elsif role == "tool"
79
+ { role: "user", content: [{ type: "tool_result", tool_use_id: m[:tool_call_id] || m["tool_call_id"], content: content.to_s }] }
80
+ else
81
+ { role: role, content: content }
82
+ end
83
+ end
84
+
85
+ # Consecutive tool results must live in one user message.
86
+ def merge_tool_results(chat)
87
+ chat.each_with_object([]) do |m, out|
88
+ prev = out.last
89
+ if prev && prev[:role] == "user" && m[:role] == "user" &&
90
+ prev[:content].is_a?(Array) && m[:content].is_a?(Array) &&
91
+ prev[:content].all? { |b| b[:type] == "tool_result" } && m[:content].all? { |b| b[:type] == "tool_result" }
92
+ prev[:content].concat(m[:content])
93
+ else
94
+ out << m
95
+ end
96
+ end
97
+ end
98
+
99
+ def anthropic_tools(tools)
100
+ Array(tools).map { |t| { name: t[:name], description: t[:description].to_s, input_schema: t[:parameters] } }
101
+ end
102
+
103
+ def anthropic_tool_calls_entry(data)
104
+ calls = Array(data["content"]).select { |b| b["type"] == "tool_use" }
105
+ return {} if calls.empty?
106
+
107
+ { tool_calls: calls.map { |b| { id: b["id"], name: b["name"], arguments: b["input"] || {}, raw_arguments: JSON.generate(b["input"] || {}) } } }
59
108
  end
60
109
 
61
110
  def api_key
@@ -6,6 +6,12 @@ module ActiveHarness
6
6
  HTTP = ActiveHarness::Http::Client.new
7
7
  STREAMING_HTTP = ActiveHarness::Http::StreamingClient.new
8
8
 
9
+ # Providers that can send `tools:` and return `tool_calls:` override this
10
+ # (see Providers::ToolCalling). Everything else is rejected by Request.
11
+ def supports_tools?
12
+ false
13
+ end
14
+
9
15
  private
10
16
 
11
17
  def config
@@ -5,11 +5,14 @@ module ActiveHarness
5
5
  # DeepSeek — OpenAI-compatible API.
6
6
  # https://platform.deepseek.com/api-docs
7
7
  class DeepSeek < Base
8
- def call(model:, messages:, temperature: 0.7, stream: nil)
8
+ include ToolCalling
9
+
10
+ def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
9
11
  headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
10
- body = { model: model, messages: messages, temperature: temperature }
12
+ body = { model: model, messages: openai_messages(messages), temperature: temperature }
13
+ body[:tools] = openai_tools(tools) if tools?(tools)
11
14
 
12
- return call_streaming(url: config.deepseek_api_url, headers: headers, body: body, stream: stream, provider: :deepseek, model: model) if stream
15
+ return call_streaming(url: config.deepseek_api_url, headers: headers, body: body, stream: stream, provider: :deepseek, model: model) if stream && !tools?(tools)
13
16
 
14
17
  raw = post_json(URI(config.deepseek_api_url), headers: headers, body: body)
15
18
  data = parse!(raw)
@@ -19,7 +22,7 @@ module ActiveHarness
19
22
  content: data.dig("choices", 0, "message", "content").to_s.strip,
20
23
  provider: :deepseek,
21
24
  model: data["model"] || model,
22
- usage: extract_usage_openai(data)
25
+ usage: extract_usage_openai(data), **openai_tool_calls_entry(data)
23
26
  }
24
27
  end
25
28
 
@@ -13,20 +13,23 @@ module ActiveHarness
13
13
  # use provider: :gpustack, model: "Qwen/Qwen2.5-7B-Instruct-GGUF"
14
14
  # end
15
15
  class GPUStack < Base
16
- def call(model:, messages:, temperature: 0.7, stream: nil)
16
+ include ToolCalling
17
+
18
+ def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
17
19
  url = "#{api_base}/v1/chat/completions"
18
20
  headers = { "Content-Type" => "application/json" }
19
21
  key = api_key
20
22
  headers["Authorization"] = "Bearer #{key}" if key
21
- body = { model: model, messages: messages, temperature: temperature }
23
+ body = { model: model, messages: openai_messages(messages), temperature: temperature }
24
+ body[:tools] = openai_tools(tools) if tools?(tools)
22
25
 
23
- return call_streaming(url: url, headers: headers, body: body, stream: stream, provider: :gpustack, model: model) if stream
26
+ return call_streaming(url: url, headers: headers, body: body, stream: stream, provider: :gpustack, model: model) if stream && !tools?(tools)
24
27
 
25
28
  raw = post_json(URI(url), headers: headers, body: body)
26
29
  data = parse!(raw)
27
30
  handle_error!(data)
28
31
 
29
- { content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :gpustack, model: data["model"] || model, usage: extract_usage_openai(data) }
32
+ { content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :gpustack, model: data["model"] || model, usage: extract_usage_openai(data), **openai_tool_calls_entry(data) }
30
33
  end
31
34
 
32
35
  private
@@ -5,17 +5,20 @@ module ActiveHarness
5
5
  # Groq — OpenAI-compatible API with fast inference.
6
6
  # https://console.groq.com/docs/openai
7
7
  class Groq < Base
8
- def call(model:, messages:, temperature: 0.7, stream: nil)
8
+ include ToolCalling
9
+
10
+ def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
9
11
  headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
10
- body = { model: model, messages: messages, temperature: temperature }
12
+ body = { model: model, messages: openai_messages(messages), temperature: temperature }
13
+ body[:tools] = openai_tools(tools) if tools?(tools)
11
14
 
12
- return call_streaming(url: config.groq_api_url, headers: headers, body: body, stream: stream, provider: :groq, model: model) if stream
15
+ return call_streaming(url: config.groq_api_url, headers: headers, body: body, stream: stream, provider: :groq, model: model) if stream && !tools?(tools)
13
16
 
14
17
  raw = post_json(URI(config.groq_api_url), headers: headers, body: body)
15
18
  data = parse!(raw)
16
19
  handle_error!(data)
17
20
 
18
- { content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :groq, model: data["model"] || model, usage: extract_usage_openai(data) }
21
+ { content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :groq, model: data["model"] || model, usage: extract_usage_openai(data), **openai_tool_calls_entry(data) }
19
22
  end
20
23
 
21
24
  private
@@ -13,11 +13,14 @@ module ActiveHarness
13
13
  # use provider: :hetzner, model: "meta-llama/Llama-3.3-70B-Instruct"
14
14
  # end
15
15
  class Hetzner < Base
16
- def call(model:, messages:, temperature: 0.7, stream: nil)
16
+ include ToolCalling
17
+
18
+ def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
17
19
  headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
18
- body = { model: model, messages: messages, temperature: temperature }
20
+ body = { model: model, messages: openai_messages(messages), temperature: temperature }
21
+ body[:tools] = openai_tools(tools) if tools?(tools)
19
22
 
20
- return call_streaming(url: config.hetzner_api_url, headers: headers, body: body, stream: stream, provider: :hetzner, model: model) if stream
23
+ return call_streaming(url: config.hetzner_api_url, headers: headers, body: body, stream: stream, provider: :hetzner, model: model) if stream && !tools?(tools)
21
24
 
22
25
  raw = post_json(URI(config.hetzner_api_url), headers: headers, body: body)
23
26
  data = parse!(raw)
@@ -27,7 +30,7 @@ module ActiveHarness
27
30
  content: data.dig("choices", 0, "message", "content").to_s.strip,
28
31
  provider: :hetzner,
29
32
  model: data["model"] || model,
30
- usage: extract_usage_openai(data)
33
+ usage: extract_usage_openai(data), **openai_tool_calls_entry(data)
31
34
  }
32
35
  end
33
36
 
@@ -5,11 +5,14 @@ module ActiveHarness
5
5
  # Mistral AI — OpenAI-compatible API.
6
6
  # https://docs.mistral.ai/api
7
7
  class Mistral < Base
8
- def call(model:, messages:, temperature: 0.7, stream: nil)
8
+ include ToolCalling
9
+
10
+ def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
9
11
  headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
10
- body = { model: model, messages: messages, temperature: temperature }
12
+ body = { model: model, messages: openai_messages(messages), temperature: temperature }
13
+ body[:tools] = openai_tools(tools) if tools?(tools)
11
14
 
12
- return call_streaming(url: config.mistral_api_url, headers: headers, body: body, stream: stream, provider: :mistral, model: model) if stream
15
+ return call_streaming(url: config.mistral_api_url, headers: headers, body: body, stream: stream, provider: :mistral, model: model) if stream && !tools?(tools)
13
16
 
14
17
  raw = post_json(URI(config.mistral_api_url), headers: headers, body: body)
15
18
  data = parse!(raw)
@@ -19,7 +22,7 @@ module ActiveHarness
19
22
  content: data.dig("choices", 0, "message", "content").to_s.strip,
20
23
  provider: :mistral,
21
24
  model: data["model"] || model,
22
- usage: extract_usage_openai(data)
25
+ usage: extract_usage_openai(data), **openai_tool_calls_entry(data)
23
26
  }
24
27
  end
25
28
 
@@ -13,20 +13,23 @@ module ActiveHarness
13
13
  # use provider: :ollama, model: "llama3.2"
14
14
  # end
15
15
  class Ollama < Base
16
- def call(model:, messages:, temperature: 0.7, stream: nil)
16
+ include ToolCalling
17
+
18
+ def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
17
19
  url = "#{api_base}/v1/chat/completions"
18
20
  headers = { "Content-Type" => "application/json" }
19
21
  key = api_key
20
22
  headers["Authorization"] = "Bearer #{key}" if key
21
- body = { model: model, messages: messages, temperature: temperature }
23
+ body = { model: model, messages: openai_messages(messages), temperature: temperature }
24
+ body[:tools] = openai_tools(tools) if tools?(tools)
22
25
 
23
- return call_streaming(url: url, headers: headers, body: body, stream: stream, provider: :ollama, model: model) if stream
26
+ return call_streaming(url: url, headers: headers, body: body, stream: stream, provider: :ollama, model: model) if stream && !tools?(tools)
24
27
 
25
28
  raw = post_json(URI(url), headers: headers, body: body)
26
29
  data = parse!(raw)
27
30
  handle_error!(data)
28
31
 
29
- { content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :ollama, model: data["model"] || model, usage: extract_usage_openai(data) }
32
+ { content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :ollama, model: data["model"] || model, usage: extract_usage_openai(data), **openai_tool_calls_entry(data) }
30
33
  end
31
34
 
32
35
  private
@@ -12,11 +12,14 @@ module ActiveHarness
12
12
  # use provider: :ollama_cloud, model: "gpt-oss:120b"
13
13
  # end
14
14
  class OllamaCloud < Base
15
- def call(model:, messages:, temperature: 0.7, stream: nil)
15
+ include ToolCalling
16
+
17
+ def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
16
18
  headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
17
- body = { model: model, messages: messages, temperature: temperature }
19
+ body = { model: model, messages: openai_messages(messages), temperature: temperature }
20
+ body[:tools] = openai_tools(tools) if tools?(tools)
18
21
 
19
- return call_streaming(url: config.ollama_cloud_api_url, headers: headers, body: body, stream: stream, provider: :ollama_cloud, model: model) if stream
22
+ return call_streaming(url: config.ollama_cloud_api_url, headers: headers, body: body, stream: stream, provider: :ollama_cloud, model: model) if stream && !tools?(tools)
20
23
 
21
24
  raw = post_json(URI(config.ollama_cloud_api_url), headers: headers, body: body)
22
25
  data = parse!(raw)
@@ -26,7 +29,7 @@ module ActiveHarness
26
29
  content: data.dig("choices", 0, "message", "content").to_s.strip,
27
30
  provider: :ollama_cloud,
28
31
  model: data["model"] || model,
29
- usage: extract_usage_openai(data)
32
+ usage: extract_usage_openai(data), **openai_tool_calls_entry(data)
30
33
  }
31
34
  end
32
35
 
@@ -3,21 +3,24 @@ require "uri"
3
3
  module ActiveHarness
4
4
  module Providers
5
5
  class OpenAI < Base
6
+ include ToolCalling
7
+
6
8
  # @param model [String]
7
9
  # @param messages [Array<Hash>] [{role:, content:}, ...]
8
10
  # @param temperature [Float]
9
11
  # @return [Hash] { content:, provider:, model: }
10
- def call(model:, messages:, temperature: 0.7, stream: nil)
12
+ def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
11
13
  headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
12
- body = { model: model, messages: messages, temperature: temperature }
14
+ body = { model: model, messages: openai_messages(messages), temperature: temperature }
15
+ body[:tools] = openai_tools(tools) if tools?(tools)
13
16
 
14
- return call_streaming(url: config.openai_api_url, headers: headers, body: body, stream: stream, provider: :openai, model: model) if stream
17
+ return call_streaming(url: config.openai_api_url, headers: headers, body: body, stream: stream, provider: :openai, model: model) if stream && !tools?(tools)
15
18
 
16
19
  raw = post_json(URI(config.openai_api_url), headers: headers, body: body)
17
20
  data = parse!(raw)
18
21
  handle_error!(data)
19
22
 
20
- { content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :openai, model: data["model"] || model, usage: extract_usage_openai(data) }
23
+ { content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :openai, model: data["model"] || model, usage: extract_usage_openai(data), **openai_tool_calls_entry(data) }
21
24
  end
22
25
 
23
26
  private
@@ -4,20 +4,23 @@ module ActiveHarness
4
4
  module Providers
5
5
  # OpenRouter — OpenAI-compatible API that proxies many models.
6
6
  class OpenRouter < Base
7
+ include ToolCalling
8
+
7
9
  # @param model [String]
8
10
  # @param messages [Array<Hash>] [{role:, content:}, ...]
9
11
  # @param temperature [Float]
10
12
  # @param stream [Proc, nil] if given, called with each token as it arrives
11
13
  # @return [Hash] { content:, provider:, model: }
12
- def call(model:, messages:, temperature: 0.7, stream: nil)
14
+ def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
13
15
  headers = {
14
16
  "Content-Type" => "application/json",
15
17
  "Authorization" => "Bearer #{api_key}",
16
18
  "HTTP-Referer" => config.openrouter_http_referer
17
19
  }
18
- body = { model: model, messages: messages, temperature: temperature }
20
+ body = { model: model, messages: openai_messages(messages), temperature: temperature }
21
+ body[:tools] = openai_tools(tools) if tools?(tools)
19
22
 
20
- return call_streaming(url: config.openrouter_api_url, headers: headers, body: body, stream: stream, provider: :openrouter, model: model) if stream
23
+ return call_streaming(url: config.openrouter_api_url, headers: headers, body: body, stream: stream, provider: :openrouter, model: model) if stream && !tools?(tools)
21
24
 
22
25
  raw = post_json(URI(config.openrouter_api_url), headers: headers, body: body)
23
26
  data = parse!(raw)
@@ -27,7 +30,7 @@ module ActiveHarness
27
30
  content: data.dig("choices", 0, "message", "content").to_s.strip,
28
31
  provider: :openrouter,
29
32
  model: data["model"] || model,
30
- usage: extract_usage_openai(data)
33
+ usage: extract_usage_openai(data), **openai_tool_calls_entry(data)
31
34
  }
32
35
  end
33
36
 
@@ -0,0 +1,95 @@
1
+ require "json"
2
+
3
+ module ActiveHarness
4
+ module Providers
5
+ # Tool-calling support shared by OpenAI-compatible providers.
6
+ #
7
+ # ActiveHarness speaks to providers in a neutral shape:
8
+ #
9
+ # tools: [{ name: "os__ls", description: "...", parameters: { type: "object", ... } }]
10
+ # messages: [{ role: :user, content: "..." },
11
+ # { role: :assistant, content: "", tool_calls: [{ id:, name:, arguments: {} }] },
12
+ # { role: :tool, tool_call_id: "...", name: "os__ls", content: "..." }]
13
+ #
14
+ # and receives tool calls back as [{ id:, name:, arguments: Hash|nil, raw_arguments: String }].
15
+ # This module maps that shape to and from the OpenAI wire format.
16
+ module ToolCalling
17
+ def supports_tools?
18
+ true
19
+ end
20
+
21
+ private
22
+
23
+ def tools?(tools)
24
+ !tools.nil? && !tools.empty?
25
+ end
26
+
27
+ def openai_tools(tools)
28
+ Array(tools).map do |t|
29
+ {
30
+ type: "function",
31
+ function: { name: t[:name], description: t[:description].to_s, parameters: t[:parameters] }
32
+ }
33
+ end
34
+ end
35
+
36
+ def openai_messages(messages)
37
+ messages.map { |m| openai_message(m) }
38
+ end
39
+
40
+ def openai_message(message)
41
+ role = (message[:role] || message["role"]).to_s
42
+ calls = message[:tool_calls] || message["tool_calls"]
43
+
44
+ if role == "assistant" && calls && !calls.empty?
45
+ content = message[:content] || message["content"]
46
+ {
47
+ role: "assistant",
48
+ content: content.to_s.empty? ? nil : content,
49
+ tool_calls: calls.map do |c|
50
+ {
51
+ id: c[:id],
52
+ type: "function",
53
+ function: { name: c[:name], arguments: JSON.generate(c[:arguments] || {}) }
54
+ }
55
+ end
56
+ }
57
+ elsif role == "tool"
58
+ {
59
+ role: "tool",
60
+ tool_call_id: message[:tool_call_id] || message["tool_call_id"],
61
+ content: (message[:content] || message["content"]).to_s
62
+ }
63
+ else
64
+ message
65
+ end
66
+ end
67
+
68
+ # { tool_calls: [...] } when the response contains tool calls, {} otherwise.
69
+ # Meant to be splatted into the provider's response hash.
70
+ def openai_tool_calls_entry(data)
71
+ raw = data.dig("choices", 0, "message", "tool_calls")
72
+ return {} if raw.nil? || raw.empty?
73
+
74
+ { tool_calls: raw.map { |c| parse_openai_tool_call(c) } }
75
+ end
76
+
77
+ def parse_openai_tool_call(call)
78
+ raw_args = call.dig("function", "arguments")
79
+ args =
80
+ case raw_args
81
+ when Hash then raw_args
82
+ when String then raw_args.strip.empty? ? {} : (JSON.parse(raw_args) rescue nil)
83
+ else {}
84
+ end
85
+
86
+ {
87
+ id: call["id"],
88
+ name: call.dig("function", "name"),
89
+ arguments: args.is_a?(Hash) ? args : nil,
90
+ raw_arguments: raw_args.is_a?(String) ? raw_args : raw_args.to_s
91
+ }
92
+ end
93
+ end
94
+ end
95
+ end