active_harness 0.3.8 → 0.3.10

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 (57) 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/pipeline/hooks.rb +1 -1
  7. data/lib/active_harness/providers/anthropic.rb +57 -8
  8. data/lib/active_harness/providers/base.rb +6 -0
  9. data/lib/active_harness/providers/deepseek.rb +7 -4
  10. data/lib/active_harness/providers/gpustack.rb +7 -4
  11. data/lib/active_harness/providers/groq.rb +7 -4
  12. data/lib/active_harness/providers/hetzner.rb +7 -4
  13. data/lib/active_harness/providers/mistral.rb +7 -4
  14. data/lib/active_harness/providers/ollama.rb +7 -4
  15. data/lib/active_harness/providers/ollama_cloud.rb +7 -4
  16. data/lib/active_harness/providers/openai.rb +7 -4
  17. data/lib/active_harness/providers/openrouter.rb +7 -4
  18. data/lib/active_harness/providers/tool_calling.rb +95 -0
  19. data/lib/active_harness/providers/xai.rb +7 -4
  20. data/lib/active_harness/railtie.rb +1 -1
  21. data/lib/active_harness/request/hooks.rb +1 -1
  22. data/lib/active_harness/request/providers.rb +15 -0
  23. data/lib/active_harness/request.rb +39 -7
  24. data/lib/active_harness/result.rb +4 -0
  25. data/lib/active_harness/tool/path_policy.rb +28 -0
  26. data/lib/active_harness/tool.rb +342 -0
  27. data/lib/active_harness/tribunal/hooks.rb +1 -1
  28. data/lib/active_harness.rb +4 -1
  29. data/lib/generators/active_harness/agent/agent_generator.rb +16 -0
  30. data/lib/generators/active_harness/agent/templates/agent.rb.tt +14 -0
  31. data/lib/generators/active_harness/goal/goal_generator.rb +16 -0
  32. data/lib/generators/active_harness/goal/templates/goal.rb.tt +14 -0
  33. data/lib/generators/active_harness/install/install_generator.rb +30 -25
  34. data/lib/generators/active_harness/install/templates/agents/files_agent.rb +39 -0
  35. data/lib/generators/active_harness/install/templates/concerns/agent_tracing.rb +36 -0
  36. data/lib/generators/active_harness/install/templates/concerns/pipeline_tracing.rb +28 -0
  37. data/lib/generators/active_harness/install/templates/concerns/request_tracing.rb +44 -0
  38. data/lib/generators/active_harness/install/templates/concerns/tool_tracing.rb +21 -0
  39. data/lib/generators/active_harness/install/templates/concerns/tracing_log.rb +18 -0
  40. data/lib/generators/active_harness/install/templates/concerns/tribunal_tracing.rb +28 -0
  41. data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +23 -2
  42. data/lib/generators/active_harness/install/templates/goals/answer_present_goal.rb +22 -0
  43. data/lib/generators/active_harness/install/templates/pipelines/support_pipeline.rb +8 -18
  44. data/lib/generators/active_harness/install/templates/prompts/files_prompt.rb +9 -0
  45. data/lib/generators/active_harness/install/templates/prompts/support_prompt.rb +3 -0
  46. data/lib/generators/active_harness/install/templates/requests/files_request.rb +18 -0
  47. data/lib/generators/active_harness/install/templates/requests/support_guard_request.rb +12 -0
  48. data/lib/generators/active_harness/install/templates/requests/support_request.rb +6 -0
  49. data/lib/generators/active_harness/install/templates/tools/ls_tool.rb +41 -0
  50. data/lib/generators/active_harness/install/templates/tools/read_file_tool.rb +44 -0
  51. data/lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb +7 -0
  52. data/lib/generators/active_harness/pipeline/templates/pipeline.rb.tt +5 -0
  53. data/lib/generators/active_harness/request/templates/request.rb.tt +2 -0
  54. data/lib/generators/active_harness/tool/templates/tool.rb.tt +24 -0
  55. data/lib/generators/active_harness/tool/tool_generator.rb +16 -0
  56. data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +3 -0
  57. 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
 
@@ -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
@@ -5,11 +5,14 @@ module ActiveHarness
5
5
  # xAI (Grok) — OpenAI-compatible API.
6
6
  # https://docs.x.ai/api
7
7
  class XAI < 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.xai_api_url, headers: headers, body: body, stream: stream, provider: :xai, model: model) if stream
15
+ return call_streaming(url: config.xai_api_url, headers: headers, body: body, stream: stream, provider: :xai, model: model) if stream && !tools?(tools)
13
16
 
14
17
  raw = post_json(URI(config.xai_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: :xai,
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
 
@@ -1,6 +1,6 @@
1
1
  module ActiveHarness
2
2
  class Railtie < Rails::Railtie
3
- APP_AI_DIRS = %w[requests prompts tribunals pipelines memory].freeze
3
+ APP_AI_DIRS = %w[concerns requests prompts tribunals pipelines memory agents tools goals].freeze
4
4
 
5
5
  initializer "active_harness.autoload_paths", before: :set_autoload_paths do |app|
6
6
  APP_AI_DIRS.each do |dir|
@@ -70,7 +70,7 @@ module ActiveHarness
70
70
  def fire(event, *args)
71
71
  run_hook(event, *args)
72
72
  @stream&.call(:request, event, *args)
73
- rescue IOError, ActionController::Live::ClientDisconnected
73
+ rescue ClientGone
74
74
  end
75
75
  end
76
76
  end
@@ -65,6 +65,12 @@ module ActiveHarness
65
65
  provider = resolve_provider(entry[:provider])
66
66
  messages = build_messages(system_prompt, @input)
67
67
  opts = { model: entry[:model], messages: messages }
68
+ if @tools && !@tools.empty?
69
+ unless provider.respond_to?(:supports_tools?) && provider.supports_tools?
70
+ raise Errors::InvalidRequestError, "Provider #{entry[:provider].inspect} does not support tool calling"
71
+ end
72
+ opts[:tools] = @tools
73
+ end
68
74
  opts[:temperature] = entry[:temperature] if entry[:temperature]
69
75
  opts[:stream] = @token if @token
70
76
  opts[:name] = entry[:name] if entry[:name]
@@ -138,6 +144,15 @@ module ActiveHarness
138
144
  # See ActiveHarness::Memory for examples.
139
145
  messages = []
140
146
  messages << { role: "system", content: system_prompt } if system_prompt
147
+
148
+ if @messages
149
+ # Conversation mode (used by Agent): the system prompt of the Request goes first,
150
+ # unless the conversation already carries its own system message.
151
+ convo = @messages.map { |m| m.dup }
152
+ messages.shift if messages.any? && convo.any? { |m| (m[:role] || m["role"]).to_s == "system" }
153
+ return messages + convo
154
+ end
155
+
141
156
  messages << { role: "user", content: input }
142
157
  messages
143
158
  end