active_harness 0.3.8 → 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.
- checksums.yaml +4 -4
- data/lib/active_harness/agent/goal_check.rb +26 -0
- data/lib/active_harness/agent/state.rb +90 -0
- data/lib/active_harness/agent.rb +734 -0
- data/lib/active_harness/core/errors.rb +11 -0
- data/lib/active_harness/pipeline/hooks.rb +1 -1
- data/lib/active_harness/providers/anthropic.rb +57 -8
- data/lib/active_harness/providers/base.rb +6 -0
- data/lib/active_harness/providers/deepseek.rb +7 -4
- data/lib/active_harness/providers/gpustack.rb +7 -4
- data/lib/active_harness/providers/groq.rb +7 -4
- data/lib/active_harness/providers/hetzner.rb +7 -4
- data/lib/active_harness/providers/mistral.rb +7 -4
- data/lib/active_harness/providers/ollama.rb +7 -4
- data/lib/active_harness/providers/ollama_cloud.rb +7 -4
- data/lib/active_harness/providers/openai.rb +7 -4
- data/lib/active_harness/providers/openrouter.rb +7 -4
- data/lib/active_harness/providers/tool_calling.rb +95 -0
- data/lib/active_harness/providers/xai.rb +7 -4
- data/lib/active_harness/railtie.rb +1 -1
- data/lib/active_harness/request/hooks.rb +1 -1
- data/lib/active_harness/request/providers.rb +15 -0
- data/lib/active_harness/request.rb +39 -7
- data/lib/active_harness/result.rb +4 -0
- data/lib/active_harness/tool/path_policy.rb +28 -0
- data/lib/active_harness/tool.rb +348 -0
- data/lib/active_harness/tribunal/hooks.rb +1 -1
- data/lib/active_harness.rb +4 -1
- data/lib/generators/active_harness/agent/agent_generator.rb +16 -0
- data/lib/generators/active_harness/agent/templates/agent.rb.tt +14 -0
- data/lib/generators/active_harness/goal/goal_generator.rb +16 -0
- data/lib/generators/active_harness/goal/templates/goal.rb.tt +14 -0
- data/lib/generators/active_harness/install/install_generator.rb +30 -25
- data/lib/generators/active_harness/install/templates/agents/files_agent.rb +39 -0
- data/lib/generators/active_harness/install/templates/concerns/agent_tracing.rb +36 -0
- data/lib/generators/active_harness/install/templates/concerns/pipeline_tracing.rb +28 -0
- data/lib/generators/active_harness/install/templates/concerns/request_tracing.rb +44 -0
- data/lib/generators/active_harness/install/templates/concerns/tool_tracing.rb +21 -0
- data/lib/generators/active_harness/install/templates/concerns/tracing_log.rb +18 -0
- data/lib/generators/active_harness/install/templates/concerns/tribunal_tracing.rb +28 -0
- data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +23 -2
- data/lib/generators/active_harness/install/templates/goals/answer_present_goal.rb +22 -0
- data/lib/generators/active_harness/install/templates/pipelines/support_pipeline.rb +8 -18
- data/lib/generators/active_harness/install/templates/prompts/files_prompt.rb +9 -0
- data/lib/generators/active_harness/install/templates/prompts/support_prompt.rb +3 -0
- data/lib/generators/active_harness/install/templates/requests/files_request.rb +18 -0
- data/lib/generators/active_harness/install/templates/requests/support_guard_request.rb +12 -0
- data/lib/generators/active_harness/install/templates/requests/support_request.rb +6 -0
- data/lib/generators/active_harness/install/templates/tools/ls_tool.rb +40 -0
- data/lib/generators/active_harness/install/templates/tools/read_file_tool.rb +43 -0
- data/lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb +7 -0
- data/lib/generators/active_harness/pipeline/templates/pipeline.rb.tt +5 -0
- data/lib/generators/active_harness/request/templates/request.rb.tt +2 -0
- data/lib/generators/active_harness/tool/templates/tool.rb.tt +22 -0
- data/lib/generators/active_harness/tool/tool_generator.rb +16 -0
- data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +3 -0
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
@@ -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
|